Compare commits

..

3 Commits

Author SHA1 Message Date
Zane Schepke 205493092b fix: cd 2024-10-22 01:23:35 -04:00
Zane Schepke 47472f088f bump version
revert some ui changes
2024-10-22 00:49:34 -04:00
Zane Schepke f5a62cba1b fix: preshared key password field
closes #405
2024-10-22 00:09:19 -04:00
97 changed files with 2272 additions and 3509 deletions
+13 -13
View File
@@ -34,28 +34,28 @@ on:
jobs: jobs:
check_commits: check_commits:
name: Check for New Commits
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
has_new_commits: ${{ steps.check.outputs.new_commits }} new_commits: ${{ steps.check_last_commit.outputs.new_commits }}
steps: steps:
- name: Checkout Repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 # This fetches all history so we can check commits fetch-depth: 0
- name: Check for new commits - name: Check for new commits in the last 23 hours
id: check id: check_last_commit
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# This script checks for commits newer than 23 hours ago if git log --since="23 hours ago" --oneline | grep -q .; then
NEW_COMMITS=$(git rev-list --count --after="$(date -Iseconds -d '23 hours ago')" ${{ github.sha }}) echo "New commits found in the last 23 hours."
echo "new_commits=$NEW_COMMITS" >> $GITHUB_OUTPUT echo "new_commits=true" >> $GITHUB_OUTPUT
else
echo "No new commits in the last 23 hours."
echo "new_commits=false" >> $GITHUB_OUTPUT
fi
build: build:
needs: check_commits needs: check_commits
if: ${{ needs.check_commits.outputs.has_new_commits > 0 && inputs.release_type != 'none' }} if: needs.check_commits.outputs.new_commits == 'true'
name: Build Signed APK name: Build Signed APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
-3
View File
@@ -44,8 +44,6 @@ android {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
} }
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().joinToString(separator = ", ") { "\"$it\"" }} }")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
@@ -151,7 +149,6 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material)
// test // test
testImplementation(libs.junit) testImplementation(libs.junit)
@@ -1,232 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "4c9418386f72dfac5d28ab96c1e5ea0b",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_wifi_by_shell_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWifiNameByShellEnabled",
"columnName": "is_wifi_by_shell_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingInterval",
"columnName": "ping_interval",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingCooldown",
"columnName": "ping_cooldown",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingIp",
"columnName": "ping_ip",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "null"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c9418386f72dfac5d28ab96c1e5ea0b')"
]
}
}
+12 -9
View File
@@ -3,6 +3,13 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -12,8 +19,7 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--foreground service exempt android 14--> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
tools:ignore="ProtectedPermissions" />
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -40,12 +46,6 @@
<uses-feature <uses-feature
android:name="android.hardware.screen.portrait" android:name="android.hardware.screen.portrait"
android:required="false" /> android:required="false" />
<uses-feature
android:name="android.hardware.gamepad"
android:required="false"/>
<uses-feature android:name="android.hardware.wifi"
android:required="false"/>
<queries> <queries>
<intent> <intent>
@@ -166,7 +166,6 @@
<service <service
android:name=".service.foreground.TunnelBackgroundService" android:name=".service.foreground.TunnelBackgroundService"
android:exported="false" android:exported="false"
android:persistent="true"
android:foregroundServiceType="systemExempted" android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE"> android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter> <intent-filter>
@@ -187,6 +186,10 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".receiver.BackgroundActionReceiver"
android:enabled="true"
android:exported="false"/>
<receiver <receiver
android:name=".receiver.AppUpdateReceiver" android:name=".receiver.AppUpdateReceiver"
android:exported="false"> android:exported="false">
@@ -1,14 +1,11 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.ThreadPolicy
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@@ -21,10 +18,6 @@ import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
val localeStorage: LocaleStorage by lazy {
LocaleStorage(this)
}
@Inject @Inject
@ApplicationScope @ApplicationScope
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
@@ -59,10 +52,6 @@ class WireGuardAutoTunnel : Application() {
} }
} }
override fun attachBaseContext(base: Context) {
super.attachBaseContext(LocaleUtil.getLocalizedContext(base, LocaleStorage(base).getPreferredLocale()))
}
companion object { companion object {
lateinit var instance: WireGuardAutoTunnel lateinit var instance: WireGuardAutoTunnel
private set private set
@@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@Database( @Database(
entities = [Settings::class, TunnelConfig::class], entities = [Settings::class, TunnelConfig::class],
version = 11, version = 10,
autoMigrations = autoMigrations =
[ [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
@@ -36,11 +36,6 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
AutoMigration(7, 8), AutoMigration(7, 8),
AutoMigration(8, 9), AutoMigration(8, 9),
AutoMigration(9, 10), AutoMigration(9, 10),
AutoMigration(
from = 10,
to = 11,
spec = RemoveTunnelPauseMigration::class,
),
], ],
exportSchema = true, exportSchema = true,
) )
@@ -60,9 +55,3 @@ abstract class AppDatabase : RoomDatabase() {
columnName = "is_battery_saver_enabled", columnName = "is_battery_saver_enabled",
) )
class RemoveLegacySettingColumnsMigration : AutoMigrationSpec class RemoveLegacySettingColumnsMigration : AutoMigrationSpec
@DeleteColumn(
tableName = "Settings",
columnName = "is_auto_tunnel_paused",
)
class RemoveTunnelPauseMigration : AutoMigrationSpec
@@ -21,12 +21,11 @@ class DataStoreManager(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
companion object { companion object {
val locationDisclosureShown = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN") val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val batteryDisableShown = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN") val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val currentSSID = stringPreferencesKey("CURRENT_SSID") val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
val pinLockEnabled = booleanPreferencesKey("PIN_LOCK_ENABLED") val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED")
val tunnelStatsExpanded = booleanPreferencesKey("TUNNEL_STATS_EXPANDED") val IS_TUNNEL_STATS_EXPANDED = booleanPreferencesKey("TUNNEL_STATS_EXPANDED")
val theme = stringPreferencesKey("THEME")
} }
// preferences // preferences
@@ -1,17 +0,0 @@
package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context
import android.content.SharedPreferences
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
class LocaleStorage(context: Context) {
private var preferences: SharedPreferences = context.getSharedPreferences("sp", Context.MODE_PRIVATE)
fun getPreferredLocale(): String {
return preferences.getString("preferred_locale", LocaleUtil.OPTION_PHONE_LANGUAGE)!!
}
fun setPreferredLocale(localeCode: String) {
preferences.edit().putString("preferred_locale", localeCode).apply()
}
}
@@ -1,13 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.domain package com.zaneschepke.wireguardautotunnel.data.domain
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
data class GeneralState( data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT, val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT, val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT, val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val isTunnelStatsExpanded: Boolean = IS_TUNNEL_STATS_EXPANDED, val isTunnelStatsExpanded: Boolean = IS_TUNNEL_STATS_EXPANDED,
val theme: Theme = Theme.AUTOMATIC,
) { ) {
companion object { companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
@@ -40,6 +40,11 @@ data class Settings(
defaultValue = "false", defaultValue = "false",
) )
val isMultiTunnelEnabled: Boolean = false, val isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false",
)
val isAutoTunnelPaused: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_ping_enabled", name = "is_ping_enabled",
defaultValue = "false", defaultValue = "false",
@@ -50,14 +55,4 @@ data class Settings(
defaultValue = "false", defaultValue = "false",
) )
val isAmneziaEnabled: Boolean = false, val isAmneziaEnabled: Boolean = false,
@ColumnInfo(
name = "is_wildcards_enabled",
defaultValue = "false",
)
val isWildcardsEnabled: Boolean = false,
@ColumnInfo(
name = "is_wifi_by_shell_enabled",
defaultValue = "false",
)
val isWifiNameByShellEnabled: Boolean = false,
) )
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.data.repository package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AppStateRepository { interface AppStateRepository {
@@ -25,9 +24,5 @@ interface AppStateRepository {
suspend fun setTunnelStatsExpanded(expanded: Boolean) suspend fun setTunnelStatsExpanded(expanded: Boolean)
suspend fun setTheme(theme: Theme)
suspend fun getTheme(): Theme
val generalStateFlow: Flow<GeneralState> val generalStateFlow: Flow<GeneralState>
} }
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
@@ -12,61 +11,47 @@ class DataStoreAppStateRepository(
) : ) :
AppStateRepository { AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean { override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.locationDisclosureShown) return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT ?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
} }
override suspend fun setLocationDisclosureShown(shown: Boolean) { override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.locationDisclosureShown, shown) dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
} }
override suspend fun isPinLockEnabled(): Boolean { override suspend fun isPinLockEnabled(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.pinLockEnabled) return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
} }
override suspend fun setPinLockEnabled(enabled: Boolean) { override suspend fun setPinLockEnabled(enabled: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.pinLockEnabled, enabled) dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled)
} }
override suspend fun isBatteryOptimizationDisableShown(): Boolean { override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.batteryDisableShown) return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT ?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
} }
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) { override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown) dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
} }
override suspend fun getCurrentSsid(): String? { override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.currentSSID) return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
} }
override suspend fun setCurrentSsid(ssid: String) { override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.currentSSID, ssid) dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
} }
override suspend fun isTunnelStatsExpanded(): Boolean { override suspend fun isTunnelStatsExpanded(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.tunnelStatsExpanded) return dataStoreManager.getFromStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED)
?: GeneralState.IS_TUNNEL_STATS_EXPANDED ?: GeneralState.IS_TUNNEL_STATS_EXPANDED
} }
override suspend fun setTunnelStatsExpanded(expanded: Boolean) { override suspend fun setTunnelStatsExpanded(expanded: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.tunnelStatsExpanded, expanded) dataStoreManager.saveToDataStore(DataStoreManager.IS_TUNNEL_STATS_EXPANDED, expanded)
}
override suspend fun setTheme(theme: Theme) {
dataStoreManager.saveToDataStore(DataStoreManager.theme, theme.name)
}
override suspend fun getTheme(): Theme {
return dataStoreManager.getFromStore(DataStoreManager.theme)?.let {
try {
Theme.valueOf(it)
} catch (_: IllegalArgumentException) {
Theme.AUTOMATIC
}
} ?: Theme.AUTOMATIC
} }
override val generalStateFlow: Flow<GeneralState> = override val generalStateFlow: Flow<GeneralState> =
@@ -75,16 +60,15 @@ class DataStoreAppStateRepository(
try { try {
GeneralState( GeneralState(
isLocationDisclosureShown = isLocationDisclosureShown =
pref[DataStoreManager.locationDisclosureShown] pref[DataStoreManager.LOCATION_DISCLOSURE_SHOWN]
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT, ?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT,
isBatteryOptimizationDisableShown = isBatteryOptimizationDisableShown =
pref[DataStoreManager.batteryDisableShown] pref[DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN]
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT, ?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
isPinLockEnabled = isPinLockEnabled =
pref[DataStoreManager.pinLockEnabled] pref[DataStoreManager.IS_PIN_LOCK_ENABLED]
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT, ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT,
isTunnelStatsExpanded = pref[DataStoreManager.tunnelStatsExpanded] ?: GeneralState.IS_TUNNEL_STATS_EXPANDED, isTunnelStatsExpanded = pref[DataStoreManager.IS_TUNNEL_STATS_EXPANDED] ?: GeneralState.IS_TUNNEL_STATS_EXPANDED,
theme = getTheme(),
) )
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Timber.e(e) Timber.e(e)
@@ -1,9 +1,11 @@
package com.zaneschepke.wireguardautotunnel.data.repository package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -24,6 +26,8 @@ class RoomTunnelConfigRepository(
override suspend fun save(tunnelConfig: TunnelConfig) { override suspend fun save(tunnelConfig: TunnelConfig) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
tunnelConfigDao.save(tunnelConfig) tunnelConfigDao.save(tunnelConfig)
}.also {
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
} }
} }
@@ -56,6 +60,8 @@ class RoomTunnelConfigRepository(
override suspend fun delete(tunnelConfig: TunnelConfig) { override suspend fun delete(tunnelConfig: TunnelConfig) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
tunnelConfigDao.delete(tunnelConfig) tunnelConfigDao.delete(tunnelConfig)
}.also {
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
} }
} }
@@ -9,11 +9,3 @@ annotation class Kernel
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class Userspace annotation class Userspace
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TunnelShell
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AppShell
@@ -9,7 +9,6 @@ import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@@ -25,18 +24,9 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides @Provides
@Singleton @Singleton
@TunnelShell fun provideRootShell(@ApplicationContext context: Context): RootShell {
fun provideTunnelRootShell(@ApplicationContext context: Context): RootShell {
return RootShell(context)
}
@Provides
@Singleton
@AppShell
fun provideAppRootShell(@ApplicationContext context: Context): RootShell {
return RootShell(context) return RootShell(context)
} }
@@ -49,14 +39,14 @@ class TunnelModule {
@Provides @Provides
@Singleton @Singleton
@Userspace @Userspace
fun provideUserspaceBackend(@ApplicationContext context: Context, @TunnelShell rootShell: RootShell): Backend { fun provideUserspaceBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return GoBackend(context, RootTunnelActionHandler(rootShell)) return GoBackend(context, RootTunnelActionHandler(rootShell))
} }
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend(@ApplicationContext context: Context, @TunnelShell rootShell: RootShell): Backend { fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell), RootTunnelActionHandler(rootShell)) return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell), RootTunnelActionHandler(rootShell))
} }
@@ -75,7 +65,6 @@ class TunnelModule {
tunnelConfigRepository: TunnelConfigRepository, tunnelConfigRepository: TunnelConfigRepository,
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher, @IoDispatcher ioDispatcher: CoroutineDispatcher,
serviceManager: ServiceManager,
): TunnelService { ): TunnelService {
return WireGuardTunnel( return WireGuardTunnel(
amneziaBackend, amneziaBackend,
@@ -84,13 +73,6 @@ class TunnelModule {
appDataRepository, appDataRepository,
applicationScope, applicationScope,
ioDispatcher, ioDispatcher,
serviceManager,
) )
} }
@Singleton
@Provides
fun provideServiceManager(@ApplicationContext context: Context): ServiceManager {
return ServiceManager.getInstance(context)
}
} }
@@ -7,12 +7,12 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
@AndroidEntryPoint @AndroidEntryPoint
class AppUpdateReceiver : BroadcastReceiver() { class AppUpdateReceiver : BroadcastReceiver() {
@@ -25,10 +25,7 @@ class AppUpdateReceiver : BroadcastReceiver() {
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var tunnelService: Provider<TunnelService> lateinit var tunnelService: TunnelService
@Inject
lateinit var serviceManager: ServiceManager
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
@@ -36,11 +33,11 @@ class AppUpdateReceiver : BroadcastReceiver() {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
Timber.i("Restarting services after upgrade") Timber.i("Restarting services after upgrade")
serviceManager.startAutoTunnel(true) ServiceManager.startWatcherServiceForeground(context)
} }
if (!settings.isAutoTunnelEnabled) { if (!settings.isAutoTunnelEnabled || settings.isAutoTunnelPaused) {
val tunnels = appDataRepository.tunnels.getAll().filter { it.isActive } val tunnels = appDataRepository.tunnels.getAll().filter { it.isActive }
if (tunnels.isNotEmpty()) tunnelService.get().startTunnel(tunnels.first(), true) if (tunnels.isNotEmpty()) context.startTunnelBackground(tunnels.first().id)
} }
} }
} }
@@ -0,0 +1,61 @@
package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Provider
@AndroidEntryPoint
class BackgroundActionReceiver : BroadcastReceiver() {
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var tunnelService: Provider<TunnelService>
@Inject
lateinit var tunnelConfigRepository: TunnelConfigRepository
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getIntExtra(TUNNEL_ID_EXTRA_KEY, 0)
if (id == 0) return
when (intent.action) {
ACTION_CONNECT -> {
Timber.d("Connect actions")
applicationScope.launch {
val tunnel = tunnelConfigRepository.getById(id)
tunnel?.let {
ServiceManager.startTunnelBackgroundService(context)
tunnelService.get().startTunnel(it)
}
}
}
ACTION_DISCONNECT -> {
applicationScope.launch {
val tunnel = tunnelConfigRepository.getById(id)
tunnel?.let {
ServiceManager.stopTunnelBackgroundService(context)
tunnelService.get().stopTunnel(it)
}
}
}
}
}
companion object {
const val ACTION_CONNECT = "ACTION_CONNECT"
const val ACTION_DISCONNECT = "ACTION_DISCONNECT"
const val TUNNEL_ID_EXTRA_KEY = "tunnelId"
}
}
@@ -8,6 +8,7 @@ import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,9 +28,6 @@ class BootReceiver : BroadcastReceiver() {
@ApplicationScope @ApplicationScope
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
@Inject
lateinit var serviceManager: ServiceManager
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) return if (Intent.ACTION_BOOT_COMPLETED != intent.action) return
applicationScope.launch { applicationScope.launch {
@@ -39,11 +37,11 @@ class BootReceiver : BroadcastReceiver() {
val tunState = tunnelService.get().vpnState.value.status val tunState = tunnelService.get().vpnState.value.status
if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) { if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) {
Timber.i("Starting previously active tunnel") Timber.i("Starting previously active tunnel")
tunnelService.get().startTunnel(activeTunnels.first(), true) context.startTunnelBackground(activeTunnels.first().id)
} }
if (isAutoTunnelEnabled) { if (isAutoTunnelEnabled) {
Timber.i("Starting watcher service from boot") Timber.i("Starting watcher service from boot")
serviceManager.startAutoTunnel(true) ServiceManager.startWatcherServiceForeground(context)
} }
} }
} }
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.IBinder import android.os.IBinder
@@ -12,7 +13,6 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.AppShell
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
@@ -26,14 +26,15 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -49,7 +50,6 @@ class AutoTunnelService : LifecycleService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject @Inject
@AppShell
lateinit var rootShell: Provider<RootShell> lateinit var rootShell: Provider<RootShell>
@Inject @Inject
@@ -74,9 +74,6 @@ class AutoTunnelService : LifecycleService() {
@IoDispatcher @IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@Inject
lateinit var serviceManager: ServiceManager
@Inject @Inject
@MainImmediateDispatcher @MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher lateinit var mainImmediateDispatcher: CoroutineDispatcher
@@ -91,11 +88,14 @@ class AutoTunnelService : LifecycleService() {
private var pingJob: Job? = null private var pingJob: Job? = null
private var networkEventJob: Job? = null private var networkEventJob: Job? = null
@get:Synchronized @set:Synchronized
private var running: Boolean = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(mainImmediateDispatcher) { lifecycleScope.launch(mainImmediateDispatcher) {
kotlin.runCatching { kotlin.runCatching {
launchWatcherNotification() launchNotification()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
@@ -110,14 +110,32 @@ class AutoTunnelService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
serviceManager.autoTunnelService.complete(this) if (intent != null) {
val action = intent.action
when (action) {
Action.START.name,
Action.START_FOREGROUND.name,
-> startService()
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
}
}
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
fun start() { private suspend fun launchNotification() {
if (appDataRepository.settings.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
} else {
launchWatcherNotification()
}
}
private fun startService() {
if (running) return
running = true
kotlin.runCatching { kotlin.runCatching {
lifecycleScope.launch(mainImmediateDispatcher) { lifecycleScope.launch(mainImmediateDispatcher) {
launchWatcherNotification() launchNotification()
initWakeLock() initWakeLock()
} }
startSettingsJob() startSettingsJob()
@@ -127,7 +145,7 @@ class AutoTunnelService : LifecycleService() {
} }
} }
fun stop() { private fun stopService() {
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
@@ -139,11 +157,10 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() { override fun onDestroy() {
cancelAndResetNetworkJobs() cancelAndResetNetworkJobs()
cancelAndResetPingJob() cancelAndResetPingJob()
serviceManager.autoTunnelService = CompletableDeferred()
super.onDestroy() super.onDestroy()
} }
private fun launchWatcherNotification(description: String = getString(R.string.monitoring_state_changes)) { private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
@@ -159,9 +176,13 @@ class AutoTunnelService : LifecycleService() {
) )
} }
private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
}
private fun initWakeLock() { private fun initWakeLock() {
wakeLock = wakeLock =
(getSystemService(POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
val tag = this.javaClass.name val tag = this.javaClass.name
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try { try {
@@ -209,16 +230,28 @@ class AutoTunnelService : LifecycleService() {
when (status) { when (status) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection") Timber.i("Gained Mobile data connection")
emitMobileDataConnected(true) autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = true,
)
}
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
emitMobileDataConnected(true) autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = true,
)
}
Timber.i("Mobile data capabilities changed") Timber.i("Mobile data capabilities changed")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
emitMobileDataConnected(false) autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = false,
)
}
Timber.i("Lost mobile data connection") Timber.i("Lost mobile data connection")
} }
} }
@@ -232,7 +265,8 @@ class AutoTunnelService : LifecycleService() {
runCatching { runCatching {
do { do {
val vpnState = tunnelService.get().vpnState.value val vpnState = tunnelService.get().vpnState.value
if (vpnState.status == TunnelState.UP) { val settings = appDataRepository.settings.getSettings()
if (vpnState.status == TunnelState.UP && !settings.isAutoTunnelPaused) {
if (vpnState.tunnelConfig != null) { if (vpnState.tunnelConfig != null) {
val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick) val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick)
val results = if (vpnState.tunnelConfig.pingIp != null) { val results = if (vpnState.tunnelConfig.pingIp != null) {
@@ -262,6 +296,17 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun onAutoTunnelPause(paused: Boolean) {
if (autoTunnelStateFlow.value.settings.isAutoTunnelPaused
!= paused
) {
when (paused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
}
}
}
private suspend fun watchForSettingsChanges() { private suspend fun watchForSettingsChanges() {
Timber.i("Starting settings watcher") Timber.i("Starting settings watcher")
withContext(ioDispatcher) { withContext(ioDispatcher) {
@@ -271,13 +316,12 @@ class AutoTunnelService : LifecycleService() {
old.map { it.isActive } != new.map { it.isActive } old.map { it.isActive } != new.map { it.isActive }
}, },
) { settings, tunnels -> ) { settings, tunnels ->
Timber.d("Tunnels or settings changed!")
autoTunnelStateFlow.value.copy( autoTunnelStateFlow.value.copy(
settings = settings, settings = settings,
tunnels = tunnels, tunnels = tunnels,
) )
}.collect { }.collect {
Timber.d("got new settings: ${it.settings}") onAutoTunnelPause(it.settings.isAutoTunnelPaused)
manageJobsBySettings(it.settings) manageJobsBySettings(it.settings)
autoTunnelStateFlow.emit(it) autoTunnelStateFlow.emit(it)
} }
@@ -287,12 +331,7 @@ class AutoTunnelService : LifecycleService() {
private suspend fun watchForVpnStateChanges() { private suspend fun watchForVpnStateChanges() {
Timber.i("Starting vpn state watcher") Timber.i("Starting vpn state watcher")
withContext(ioDispatcher) { withContext(ioDispatcher) {
tunnelService.get().vpnState.distinctUntilChanged { old, new -> tunnelService.get().vpnState.collect { state ->
old.tunnelConfig?.id == new.tunnelConfig?.id
}.collect { state ->
autoTunnelStateFlow.update {
it.copy(vpnState = state)
}
state.tunnelConfig?.let { state.tunnelConfig?.let {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
if (it.isPingEnabled && !settings.isPingEnabled) { if (it.isPingEnabled && !settings.isPingEnabled) {
@@ -356,7 +395,7 @@ class AutoTunnelService : LifecycleService() {
mobileDataJob = null mobileDataJob = null
} }
private fun emitEthernetConnected(connected: Boolean) { private fun updateEthernet(connected: Boolean) {
autoTunnelStateFlow.update { autoTunnelStateFlow.update {
it.copy( it.copy(
isEthernetConnected = connected, isEthernetConnected = connected,
@@ -364,7 +403,7 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun emitWifiConnected(connected: Boolean) { private fun updateWifi(connected: Boolean) {
autoTunnelStateFlow.update { autoTunnelStateFlow.update {
it.copy( it.copy(
isWifiConnected = connected, isWifiConnected = connected,
@@ -372,22 +411,6 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun emitWifiSSID(ssid: String) {
autoTunnelStateFlow.update {
it.copy(
currentNetworkSSID = ssid,
)
}
}
private fun emitMobileDataConnected(connected: Boolean) {
autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = connected,
)
}
}
private suspend fun watchForEthernetConnectivityChanges() { private suspend fun watchForEthernetConnectivityChanges() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
Timber.i("Starting ethernet data watcher") Timber.i("Starting ethernet data watcher")
@@ -395,16 +418,16 @@ class AutoTunnelService : LifecycleService() {
when (status) { when (status) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.i("Gained Ethernet connection") Timber.i("Gained Ethernet connection")
emitEthernetConnected(true) updateEthernet(true)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Ethernet capabilities changed") Timber.i("Ethernet capabilities changed")
emitEthernetConnected(true) updateEthernet(true)
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
emitEthernetConnected(false) updateEthernet(false)
Timber.i("Lost Ethernet connection") Timber.i("Lost Ethernet connection")
} }
} }
@@ -419,12 +442,12 @@ class AutoTunnelService : LifecycleService() {
when (status) { when (status) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection") Timber.i("Gained Wi-Fi connection")
emitWifiConnected(true) updateWifi(true)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed") Timber.i("Wifi capabilities changed")
emitWifiConnected(true) updateWifi(true)
val ssid = getWifiSSID(status.networkCapabilities) val ssid = getWifiSSID(status.networkCapabilities)
ssid?.let { name -> ssid?.let { name ->
if (name.contains(Constants.UNREADABLE_SSID)) { if (name.contains(Constants.UNREADABLE_SSID)) {
@@ -433,12 +456,16 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Detected valid SSID") Timber.i("Detected valid SSID")
} }
appDataRepository.appState.setCurrentSsid(name) appDataRepository.appState.setCurrentSsid(name)
emitWifiSSID(name) autoTunnelStateFlow.update {
it.copy(
currentNetworkSSID = name,
)
}
} ?: Timber.w("Failed to read ssid") } ?: Timber.w("Failed to read ssid")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
emitWifiConnected(false) updateWifi(false)
Timber.i("Lost Wi-Fi connection") Timber.i("Lost Wi-Fi connection")
} }
} }
@@ -448,8 +475,9 @@ class AutoTunnelService : LifecycleService() {
private suspend fun getWifiSSID(networkCapabilities: NetworkCapabilities): String? { private suspend fun getWifiSSID(networkCapabilities: NetworkCapabilities): String? {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
with(autoTunnelStateFlow.value.settings) { try {
if (isWifiNameByShellEnabled) return@withContext rootShell.get().getCurrentWifiName() rootShell.get().getCurrentWifiName()
} catch (_: Exception) {
wifiService.getNetworkName(networkCapabilities) wifiService.getNetworkName(networkCapabilities)
} }
} }
@@ -459,95 +487,100 @@ class AutoTunnelService : LifecycleService() {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull() return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
} }
private fun isTunnelDown(): Boolean {
return tunnelService.get().vpnState.value.status == TunnelState.DOWN
}
private suspend fun handleNetworkEventChanges() { private suspend fun handleNetworkEventChanges() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
Timber.i("Starting network event watcher") Timber.i("Starting network event watcher")
autoTunnelStateFlow.collect { watcherState -> autoTunnelStateFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher" val autoTunnel = "Auto-tunnel watcher"
// delay for rapid network state changes and then collect latest if (!watcherState.settings.isAutoTunnelPaused) {
delay(Constants.WATCHER_COLLECTION_DELAY) // delay for rapid network state changes and then collect latest
val activeTunnel = watcherState.vpnState.tunnelConfig delay(Constants.WATCHER_COLLECTION_DELAY)
val defaultTunnel = appDataRepository.getPrimaryOrFirstTunnel() val activeTunnel = tunnelService.get().vpnState.value.tunnelConfig
val isTunnelDown = tunnelService.get().getState() == TunnelState.DOWN val defaultTunnel = appDataRepository.getPrimaryOrFirstTunnel()
when { when {
watcherState.isEthernetConditionMet() -> { watcherState.isEthernetConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on ethernet condition met") Timber.i("$autoTunnel - tunnel on on ethernet condition met")
if (isTunnelDown) { if (isTunnelDown()) {
defaultTunnel?.let { defaultTunnel?.let {
tunnelService.get().startTunnel(it)
}
}
}
watcherState.isMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel on mobile data condition met")
val mobileDataTunnel = getMobileDataTunnel()
val tunnel =
mobileDataTunnel ?: defaultTunnel
if (isTunnelDown || activeTunnel?.isMobileDataTunnel == false) {
tunnel?.let {
tunnelService.get().startTunnel(it)
}
}
}
watcherState.isTunnelOffOnMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
if (!isTunnelDown) {
activeTunnel?.let {
tunnelService.get().stopTunnel(it)
}
}
}
watcherState.isUntrustedWifiConditionMet() -> {
Timber.i("Untrusted wifi condition met")
if (activeTunnel == null || watcherState.isCurrentSSIDActiveTunnelNetwork() == false ||
isTunnelDown
) {
Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
)
watcherState.getTunnelWithMatchingTunnelNetwork()?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown || activeTunnel?.id != it.id) {
tunnelService.get().startTunnel(it) tunnelService.get().startTunnel(it)
} }
} ?: suspend { }
Timber.i("No tunnel associated with this SSID, using defaults") }
val default = appDataRepository.getPrimaryOrFirstTunnel()
if (default?.name != tunnelService.get().name || isTunnelDown) { watcherState.isMobileDataConditionMet() -> {
default?.let { Timber.i("$autoTunnel - tunnel on mobile data condition met")
val mobileDataTunnel = getMobileDataTunnel()
val tunnel =
mobileDataTunnel ?: defaultTunnel
if (isTunnelDown() || activeTunnel?.isMobileDataTunnel == false) {
tunnel?.let {
tunnelService.get().startTunnel(it)
}
}
}
watcherState.isTunnelOffOnMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
if (!isTunnelDown()) {
activeTunnel?.let {
tunnelService.get().stopTunnel(it)
}
}
}
watcherState.isUntrustedWifiConditionMet() -> {
Timber.i("Untrusted wifi condition met")
if (activeTunnel?.tunnelNetworks?.isMatchingToWildcardList(watcherState.currentNetworkSSID) == false ||
activeTunnel == null || isTunnelDown()
) {
Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
)
watcherState.tunnels.firstOrNull { it.tunnelNetworks.isMatchingToWildcardList(watcherState.currentNetworkSSID) }?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown() || activeTunnel?.id != it.id) {
tunnelService.get().startTunnel(it) tunnelService.get().startTunnel(it)
} }
} } ?: suspend {
}.invoke() Timber.i("No tunnel associated with this SSID, using defaults")
val default = appDataRepository.getPrimaryOrFirstTunnel()
if (default?.name != tunnelService.get().name || isTunnelDown()) {
default?.let {
tunnelService.get().startTunnel(it)
}
}
}.invoke()
}
} }
}
watcherState.isTrustedWifiConditionMet() -> { watcherState.isTrustedWifiConditionMet() -> {
Timber.i( Timber.i(
"$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off", "$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off",
) )
if (!isTunnelDown) activeTunnel?.let { tunnelService.get().stopTunnel(it) } if (!isTunnelDown()) activeTunnel?.let { tunnelService.get().stopTunnel(it) }
} }
watcherState.isTunnelOffOnWifiConditionMet() -> { watcherState.isTunnelOffOnWifiConditionMet() -> {
Timber.i( Timber.i(
"$autoTunnel - tunnel off on wifi condition met, turning vpn off", "$autoTunnel - tunnel off on wifi condition met, turning vpn off",
) )
if (!isTunnelDown) activeTunnel?.let { tunnelService.get().stopTunnel(it) } if (!isTunnelDown()) activeTunnel?.let { tunnelService.get().stopTunnel(it) }
} }
// TODO disable for this now
// watcherState.isTunnelOffOnNoConnectivityMet() -> {
// Timber.i(
// "$autoTunnel - tunnel off on no connectivity met, turning vpn off",
// )
// if (!isTunnelDown) activeTunnel?.let { tunnelService.get().stopTunnel(it) }
// }
else -> { watcherState.isTunnelOffOnNoConnectivityMet() -> {
Timber.i("$autoTunnel - no condition met") Timber.i(
"$autoTunnel - tunnel off on no connectivity met, turning vpn off",
)
if (!isTunnelDown()) activeTunnel?.let { tunnelService.get().stopTunnel(it) }
}
else -> {
Timber.i("$autoTunnel - no condition met")
}
} }
} }
} }
@@ -1,13 +1,10 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState( data class AutoTunnelState(
val vpnState: VpnState = VpnState(),
val isWifiConnected: Boolean = false, val isWifiConnected: Boolean = false,
val isEthernetConnected: Boolean = false, val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false, val isMobileDataConnected: Boolean = false,
@@ -44,7 +41,7 @@ data class AutoTunnelState(
return ( return (
!isEthernetConnected && !isEthernetConnected &&
isWifiConnected && isWifiConnected &&
!isCurrentSSIDTrusted() && !settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID) &&
settings.isTunnelOnWifiEnabled settings.isTunnelOnWifiEnabled
) )
} }
@@ -54,7 +51,7 @@ data class AutoTunnelState(
!isEthernetConnected && !isEthernetConnected &&
( (
isWifiConnected && isWifiConnected &&
isCurrentSSIDTrusted() settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID)
) )
) )
} }
@@ -76,32 +73,4 @@ data class AutoTunnelState(
!isMobileDataConnected !isMobileDataConnected
) )
} }
fun isCurrentSSIDTrusted(): Boolean {
return if (settings.isWildcardsEnabled) {
settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID)
} else {
settings.trustedNetworkSSIDs.contains(currentNetworkSSID)
}
}
fun isCurrentSSIDActiveTunnelNetwork(): Boolean {
val currentTunnelNetworks = vpnState.tunnelConfig?.tunnelNetworks
return (
if (settings.isWildcardsEnabled) {
currentTunnelNetworks?.isMatchingToWildcardList(currentNetworkSSID)
} else {
currentTunnelNetworks?.contains(currentNetworkSSID)
}
) == true
}
fun getTunnelWithMatchingTunnelNetwork(): TunnelConfig? {
return tunnels.firstOrNull {
if (settings.isWildcardsEnabled) {
it.tunnelNetworks.isMatchingToWildcardList(currentNetworkSSID)
} else {
it.tunnelNetworks.contains(currentNetworkSSID)
}
}
}
} }
@@ -3,83 +3,69 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.util.SingletonHolder import android.net.VpnService
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import jakarta.inject.Inject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import timber.log.Timber import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class) object ServiceManager {
class ServiceManager private fun <T : Service> actionOnService(action: Action, context: Context, cls: Class<T>, extras: Map<String, Int>? = null) {
@Inject constructor(private val context: Context) { if (VpnService.prepare(context) != null) return
val intent =
private val _autoTunnelActive = MutableStateFlow(false) Intent(context, cls).also {
it.action = action.name
val autoTunnelActive = _autoTunnelActive.asStateFlow() extras?.forEach { (k, v) -> it.putExtra(k, v) }
var autoTunnelService = CompletableDeferred<AutoTunnelService>()
var backgroundService = CompletableDeferred<TunnelBackgroundService>()
companion object : SingletonHolder<ServiceManager, Context>(::ServiceManager)
private fun <T : Service> startService(cls: Class<T>, background: Boolean) {
runCatching {
val intent = Intent(context, cls)
if (background) {
context.startForegroundService(intent)
} else {
context.startService(intent)
} }
}.onFailure { Timber.e(it) } intent.component?.javaClass
} try {
when (action) {
Action.START_FOREGROUND, Action.STOP_FOREGROUND ->
context.startForegroundService(
intent,
)
suspend fun startAutoTunnel(background: Boolean) { Action.START, Action.STOP -> context.startService(intent)
if (autoTunnelService.isCompleted) return _autoTunnelActive.update { true } }
kotlin.runCatching { } catch (e: Exception) {
startService(AutoTunnelService::class.java, background) Timber.e(e.message)
autoTunnelService.await()
autoTunnelService.getCompleted().start()
_autoTunnelActive.update { true }
}.onFailure {
Timber.e(it)
} }
} }
suspend fun startBackgroundService() { fun startWatcherServiceForeground(context: Context) {
if (backgroundService.isCompleted) return actionOnService(
kotlin.runCatching { Action.START_FOREGROUND,
startService(TunnelBackgroundService::class.java, true) context,
backgroundService.await() AutoTunnelService::class.java,
backgroundService.getCompleted().start() )
}.onFailure {
Timber.e(it)
}
} }
fun stopBackgroundService() { fun startWatcherService(context: Context) {
if (!backgroundService.isCompleted) return actionOnService(
runCatching { Action.START,
backgroundService.getCompleted().stop() context,
}.onFailure { AutoTunnelService::class.java,
Timber.e(it) )
}
} }
fun stopAutoTunnel() { fun stopWatcherService(context: Context) {
if (!autoTunnelService.isCompleted) return actionOnService(
runCatching { Action.STOP,
autoTunnelService.getCompleted().stop() context,
_autoTunnelActive.update { false } AutoTunnelService::class.java,
}.onFailure { )
Timber.e(it)
}
} }
fun requestTunnelTileUpdate() { fun startTunnelBackgroundService(context: Context) {
context.requestTunnelTileServiceStateUpdate() actionOnService(
Action.START_FOREGROUND,
context,
TunnelBackgroundService::class.java,
)
}
fun stopTunnelBackgroundService(context: Context) {
actionOnService(
Action.STOP,
context,
TunnelBackgroundService::class.java,
)
} }
} }
@@ -3,13 +3,10 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Notification import android.app.Notification
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -18,14 +15,11 @@ class TunnelBackgroundService : LifecycleService() {
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@Inject
lateinit var serviceManager: ServiceManager
private val foregroundId = 123 private val foregroundId = 123
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
start() startForeground(foregroundId, createNotification())
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@@ -35,34 +29,32 @@ class TunnelBackgroundService : LifecycleService() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceManager.backgroundService.complete(this) if (intent != null) {
val action = intent.action
when (action) {
Action.START.name,
Action.START_FOREGROUND.name,
-> startService()
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
}
}
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
fun start() { private fun startService() {
ServiceCompat.startForeground( startForeground(foregroundId, createNotification())
this,
foregroundId,
createNotification(),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
} }
fun stop() { private fun stopService() {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
override fun onDestroy() {
serviceManager.backgroundService = CompletableDeferred()
super.onDestroy()
}
private fun createNotification(): Notification { private fun createNotification(): Notification {
return notificationService.createNotification( return notificationService.createNotification(
getString(R.string.vpn_channel_id), getString(R.string.vpn_channel_id),
getString(R.string.vpn_channel_name), getString(R.string.vpn_channel_name),
getString(R.string.tunnel_running), getString(R.string.tunnel_start_text),
description = "", description = "",
) )
} }
@@ -6,8 +6,9 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.AutoTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import com.zaneschepke.wireguardautotunnel.util.extensions.stopTunnelBackground
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,9 +24,6 @@ class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var tunnelService: Provider<TunnelService> lateinit var tunnelService: Provider<TunnelService>
@Inject
lateinit var serviceManager: ServiceManager
@Inject @Inject
@ApplicationScope @ApplicationScope
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
@@ -46,16 +44,26 @@ class ShortcutsActivity : ComponentActivity() {
Timber.d("Shortcut action on name: ${tunnelConfig?.name}") Timber.d("Shortcut action on name: ${tunnelConfig?.name}")
tunnelConfig?.let { tunnelConfig?.let {
when (intent.action) { when (intent.action) {
Action.START.name -> tunnelService.get().startTunnel(it, true) Action.START.name -> this@ShortcutsActivity.startTunnelBackground(it.id)
Action.STOP.name -> tunnelService.get().stopTunnel(it) Action.STOP.name -> this@ShortcutsActivity.stopTunnelBackground(it.id)
else -> Unit else -> Unit
} }
} }
} }
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> { AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
when (intent.action) { when (intent.action) {
Action.START.name -> serviceManager.startAutoTunnel(true) Action.START.name ->
Action.STOP.name -> serviceManager.stopAutoTunnel() appDataRepository.settings.save(
settings.copy(
isAutoTunnelPaused = false,
),
)
Action.STOP.name ->
appDataRepository.settings.save(
settings.copy(
isAutoTunnelPaused = true,
),
)
} }
} }
} }
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.service.tile package com.zaneschepke.wireguardautotunnel.service.tile
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
@@ -8,9 +9,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,18 +23,51 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
@Inject @Inject
@ApplicationScope @ApplicationScope
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
/* This works around an annoying unsolved frameworks bug some people are hitting. */
override fun onBind(intent: Intent): IBinder? {
var ret: IBinder? = null
try {
ret = super.onBind(intent)
} catch (e: Throwable) {
Timber.e("Failed to bind to AutoTunnelTile")
}
return ret
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
applicationScope.launch {
appDataRepository.settings.getSettingsFlow().collect {
kotlin.runCatching {
when (it.isAutoTunnelEnabled) {
true -> {
if (it.isAutoTunnelPaused) {
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
}
false -> {
setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
setUnavailable()
}
}
}.onFailure {
Timber.e(it)
}
}
}
} }
override fun onStopListening() { override fun onStopListening() {
@@ -48,28 +82,26 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleScope.launch {
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable()
updateTileState()
}
}
private fun updateTileState() {
serviceManager.autoTunnelActive.value.let {
if (it) setActive() else setInactive()
}
} }
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
lifecycleScope.launch { lifecycleScope.launch {
if (serviceManager.autoTunnelActive.value) { kotlin.runCatching {
serviceManager.stopAutoTunnel() val settings = appDataRepository.settings.getSettings()
setInactive() if (settings.isAutoTunnelPaused) {
} else { return@launch appDataRepository.settings.save(
serviceManager.startAutoTunnel(true) settings.copy(
setActive() isAutoTunnelPaused = false,
),
)
}
appDataRepository.settings.save(
settings.copy(
isAutoTunnelPaused = true,
),
)
} }
} }
} }
@@ -96,15 +128,16 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
} }
} }
/* This works around an annoying unsolved frameworks bug some people are hitting. */ private fun setTileDescription(description: String) {
override fun onBind(intent: Intent): IBinder? { kotlin.runCatching {
var ret: IBinder? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try { qsTile.subtitle = description
ret = super.onBind(intent) }
} catch (_: Throwable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Timber.e("Failed to bind to TunnelControlTile") qsTile.stateDescription = description
}
qsTile.updateTile()
} }
return ret
} }
override val lifecycle: Lifecycle override val lifecycle: Lifecycle
@@ -1,8 +1,6 @@
package com.zaneschepke.wireguardautotunnel.service.tile package com.zaneschepke.wireguardautotunnel.service.tile
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -13,6 +11,8 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import com.zaneschepke.wireguardautotunnel.util.extensions.stopTunnelBackground
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -36,6 +36,7 @@ class TunnelControlTile : TileService(), LifecycleOwner {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("onCreate for tile service")
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
} }
@@ -51,7 +52,6 @@ class TunnelControlTile : TileService(), LifecycleOwner {
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Updating tile!")
lifecycleScope.launch { lifecycleScope.launch {
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable() if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable()
updateTileState() updateTileState()
@@ -68,15 +68,13 @@ class TunnelControlTile : TileService(), LifecycleOwner {
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
Timber.d("Click")
lifecycleScope.launch { lifecycleScope.launch {
val context = this@TunnelControlTile
val lastActive = appDataRepository.getStartTunnelConfig() val lastActive = appDataRepository.getStartTunnelConfig()
lastActive?.let { tunnel -> lastActive?.let { tunnel ->
if (tunnel.isActive) { if (tunnel.isActive) return@launch context.stopTunnelBackground(tunnel.id)
tunnelService.get().stopTunnel(tunnel) context.startTunnelBackground(tunnel.id)
} else {
tunnelService.get().startTunnel(tunnel, true)
}
updateTileState()
} }
} }
} }
@@ -126,17 +124,6 @@ class TunnelControlTile : TileService(), LifecycleOwner {
} }
} }
/* This works around an annoying unsolved frameworks bug some people are hitting. */
override fun onBind(intent: Intent): IBinder? {
var ret: IBinder? = null
try {
ret = super.onBind(intent)
} catch (_: Throwable) {
Timber.e("Failed to bind to TunnelControlTile")
}
return ret
}
override val lifecycle: Lifecycle override val lifecycle: Lifecycle
get() = lifecycleRegistry get() = lifecycleRegistry
} }
@@ -5,7 +5,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel { interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig, background: Boolean = false): Result<TunnelState> suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState>
suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState>
@@ -18,6 +18,5 @@ interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun getState(): TunnelState suspend fun getState(): TunnelState
fun cancelStatsJob() fun cancelStatsJob()
fun startStatsJob() fun startStatsJob()
} }
@@ -8,7 +8,6 @@ import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepositor
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
@@ -25,7 +24,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -38,7 +36,6 @@ constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceManager: ServiceManager,
) : TunnelService { ) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState()) private val _vpnState = MutableStateFlow(VpnState())
@@ -53,8 +50,6 @@ constructor(
private var statsJob: Job? = null private var statsJob: Job? = null
private val runningHandle = AtomicBoolean(false)
private suspend fun backend(): Any { private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get() if (settings.isKernelEnabled) return kernelBackend.get()
@@ -92,16 +87,9 @@ constructor(
} }
} }
override suspend fun startTunnel(tunnelConfig: TunnelConfig, background: Boolean): Result<TunnelState> { override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
if (runningHandle.get() == true && tunnelConfig == vpnState.value.tunnelConfig) {
Timber.w("Tunnel already running")
return@withContext Result.success(vpnState.value.status)
}
runningHandle.set(true)
onBeforeStart(tunnelConfig) onBeforeStart(tunnelConfig)
val settings = appDataRepository.settings.getSettings()
if (background || settings.isKernelEnabled) startBackgroundService()
setState(tunnelConfig, TunnelState.UP).onSuccess { setState(tunnelConfig, TunnelState.UP).onSuccess {
emitTunnelState(it) emitTunnelState(it)
}.onFailure { }.onFailure {
@@ -119,9 +107,6 @@ constructor(
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
onStopFailed() onStopFailed()
}.also {
stopBackgroundService()
runningHandle.set(false)
} }
} }
} }
@@ -156,39 +141,20 @@ constructor(
} }
cancelStatsJob() cancelStatsJob()
resetBackendStatistics() resetBackendStatistics()
runningHandle.set(false)
}
private suspend fun shutDownActiveTunnel(config: TunnelConfig) {
with(_vpnState.value) {
if (status == TunnelState.UP && tunnelConfig != config) {
tunnelConfig?.let { stopTunnel(it) }
}
}
}
private suspend fun startBackgroundService() {
serviceManager.startBackgroundService()
serviceManager.requestTunnelTileUpdate()
}
private fun stopBackgroundService() {
serviceManager.stopBackgroundService()
serviceManager.requestTunnelTileUpdate()
} }
private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) { private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) {
shutDownActiveTunnel(tunnelConfig) if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true)) appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
emitVpnStateConfig(tunnelConfig) emitVpnStateConfig(tunnelConfig)
resetBackendStatistics()
startStatsJob() startStatsJob()
} }
private suspend fun onBeforeStop(tunnelConfig: TunnelConfig) { private suspend fun onBeforeStop(tunnelConfig: TunnelConfig) {
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
cancelStatsJob() cancelStatsJob()
resetBackendStatistics() resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
} }
private fun emitTunnelState(state: TunnelState) { private fun emitTunnelState(state: TunnelState) {
@@ -10,5 +10,4 @@ data class AppUiState(
val tunnels: List<TunnelConfig> = emptyList(), val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val generalState: GeneralState = GeneralState(), val generalState: GeneralState = GeneralState(),
val autoTunnelActive: Boolean = false,
) )
@@ -9,6 +9,7 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,34 +33,34 @@ constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val tunnelService: Provider<TunnelService>, private val tunnelService: Provider<TunnelService>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceManager: ServiceManager,
) : ViewModel() { ) : ViewModel() {
private val _appUiState = MutableStateFlow(AppUiState())
val uiState = val uiState =
combine( combine(
appDataRepository.settings.getSettingsFlow(), appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(), appDataRepository.tunnels.getTunnelConfigsFlow(),
tunnelService.get().vpnState, tunnelService.get().vpnState,
appDataRepository.appState.generalStateFlow, appDataRepository.appState.generalStateFlow,
serviceManager.autoTunnelActive, ) { settings, tunnels, tunnelState, generalState ->
) { settings, tunnels, tunnelState, generalState, autoTunnel ->
AppUiState( AppUiState(
settings, settings,
tunnels, tunnels,
tunnelState, tunnelState,
generalState, generalState,
autoTunnel,
) )
}.stateIn( }.stateIn(
viewModelScope + ioDispatcher, viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
AppUiState(), _appUiState.value,
) )
private val _isAppReady = MutableStateFlow<Boolean>(false) private val _isAppReady = MutableStateFlow<Boolean>(false)
val isAppReady = _isAppReady.asStateFlow() val isAppReady = _isAppReady.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
initPin() initPin()
initAutoTunnel() initAutoTunnel()
@@ -92,7 +93,15 @@ constructor(
private suspend fun initAutoTunnel() { private suspend fun initAutoTunnel() {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
if (settings.isAutoTunnelEnabled) serviceManager.startAutoTunnel(false) if (settings.isAutoTunnelEnabled) ServiceManager.startWatcherService(WireGuardAutoTunnel.instance)
}
fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch(ioDispatcher) {
_appUiState.emit(
_appUiState.value.copy(
tunnels = tunnels,
),
)
} }
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) { fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
@@ -103,8 +112,4 @@ constructor(
fun onPinLockEnabled() = viewModelScope.launch { fun onPinLockEnabled() = viewModelScope.launch {
appDataRepository.appState.setPinLockEnabled(true) appDataRepository.appState.setPinLockEnabled(true)
} }
fun setLocationDisclosureShown() = viewModelScope.launch {
appDataRepository.appState.setLocationDisclosureShown(true)
}
} }
@@ -1,15 +1,16 @@
package com.zaneschepke.wireguardautotunnel.ui package com.zaneschepke.wireguardautotunnel.ui
import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -27,24 +28,28 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalFocusRequester
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
@@ -52,31 +57,17 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.scanner.ScannerScreen import com.zaneschepke.wireguardautotunnel.ui.screens.scanner.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val localeStorage: LocaleStorage by lazy {
(application as WireGuardAutoTunnel).localeStorage
}
private lateinit var oldPrefLocaleCode: String
@Inject @Inject
lateinit var appStateRepository: AppStateRepository lateinit var appStateRepository: AppStateRepository
@@ -87,6 +78,12 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb(),
),
)
installSplashScreen().apply { installSplashScreen().apply {
setKeepOnScreenCondition { setKeepOnScreenCondition {
@@ -97,126 +94,124 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle) val appUiState by viewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle)
val navController = rememberNavController() val navController = rememberNavController()
val rootItemFocusRequester = remember { FocusRequester() } val navBackStackEntry by navController.currentBackStackEntryAsState()
LaunchedEffect(appUiState.tunnels) { LaunchedEffect(appUiState.vpnState.status) {
Timber.d("Updating launched") val context = this@MainActivity
requestTunnelTileServiceStateUpdate() when (appUiState.vpnState.status) {
} TunnelState.DOWN -> ServiceManager.stopTunnelBackgroundService(context)
else -> Unit
LaunchedEffect(appUiState.autoTunnelActive) { }
requestAutoTunnelTileServiceUpdate() context.requestTunnelTileServiceStateUpdate()
} }
with(appUiState.settings) { with(appUiState.settings) {
LaunchedEffect(isAutoTunnelEnabled) { LaunchedEffect(isAutoTunnelPaused, isAutoTunnelEnabled) {
this@MainActivity.requestAutoTunnelTileServiceUpdate() this@MainActivity.requestAutoTunnelTileServiceUpdate()
} }
} }
CompositionLocalProvider(LocalFocusRequester provides rootItemFocusRequester) { CompositionLocalProvider(LocalNavController provides navController) {
CompositionLocalProvider(LocalNavController provides navController) { SnackbarControllerProvider { host ->
SnackbarControllerProvider { host -> WireguardAutoTunnelTheme {
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) { val focusRequester = remember { FocusRequester() }
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0.dp), snackbarHost = {
snackbarHost = { SnackbarHost(host) { snackbarData: SnackbarData ->
SnackbarHost(host) { snackbarData: SnackbarData -> CustomSnackBar(
CustomSnackBar( snackbarData.visuals.message,
snackbarData.visuals.message, isRtl = false,
isRtl = false, containerColor =
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
MaterialTheme.colorScheme.surfaceColorAtElevation( 2.dp,
2.dp,
),
)
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
),
), ),
) )
}
},
modifier =
Modifier
.focusable()
.focusProperties {
if (navBackStackEntry?.isCurrentRoute(Route.Lock) == true) {
Unit
} else {
up = focusRequester
}
}, },
) { bottomBar = {
Box(modifier = Modifier.fillMaxSize().padding(it)) { BottomNavBar(
NavHost( navController,
navController, listOf(
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) }, BottomNavItem(
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) }, name = stringResource(R.string.tunnels),
startDestination = (if (appUiState.generalState.isPinLockEnabled == true) Route.Lock else Route.Main), route = Route.Main,
) { icon = Icons.Rounded.Home,
composable<Route.Main> { ),
MainScreen( BottomNavItem(
uiState = appUiState, name = stringResource(R.string.settings),
) route = Route.Settings,
} icon = Icons.Rounded.Settings,
composable<Route.Settings> { ),
SettingsScreen( BottomNavItem(
appViewModel = viewModel, name = stringResource(R.string.support),
uiState = appUiState, route = Route.Support,
) icon = Icons.Rounded.QuestionMark,
} ),
composable<Route.LocationDisclosure> { ),
LocationDisclosureScreen(viewModel, appUiState) )
} },
composable<Route.AutoTunnel> { ) { padding ->
AutoTunnelScreen( Box(modifier = Modifier.fillMaxSize().padding(padding)) {
appUiState, NavHost(
) navController,
} enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
composable<Route.Appearance> { exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
AppearanceScreen() startDestination = (if (appUiState.generalState.isPinLockEnabled == true) Route.Lock else Route.Main),
} ) {
composable<Route.Language> { composable<Route.Main> {
LanguageScreen(localeStorage) MainScreen(
} focusRequester = focusRequester,
composable<Route.Display> { uiState = appUiState,
DisplayScreen(appUiState) )
} }
composable<Route.Support> { composable<Route.Settings> {
SupportScreen() SettingsScreen(
} appViewModel = viewModel,
composable<Route.Logs> { uiState = appUiState,
LogsScreen() focusRequester = focusRequester,
} )
composable<Route.Config> { }
val args = it.toRoute<Route.Config>() composable<Route.Support> {
ConfigScreen( SupportScreen(
tunnelId = args.id, focusRequester = focusRequester,
) appUiState = appUiState,
} )
composable<Route.Option> { }
val args = it.toRoute<Route.Option>() composable<Route.Logs> {
OptionsScreen( LogsScreen()
tunnelId = args.id, }
appUiState = appUiState, composable<Route.Config> {
) val args = it.toRoute<Route.Config>()
} ConfigScreen(
composable<Route.Lock> { focusRequester = focusRequester,
PinLockScreen( tunnelId = args.id,
appViewModel = viewModel, )
) }
} composable<Route.Option> {
composable<Route.Scanner> { val args = it.toRoute<Route.Option>()
ScannerScreen() OptionsScreen(
} tunnelId = args.id,
focusRequester = focusRequester,
appUiState = appUiState,
)
}
composable<Route.Lock> {
PinLockScreen(
appViewModel = viewModel,
)
}
composable<Route.Scanner> {
ScannerScreen()
} }
} }
} }
@@ -227,21 +222,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
override fun attachBaseContext(newBase: Context) {
oldPrefLocaleCode = LocaleStorage(newBase).getPreferredLocale()
applyOverrideConfiguration(LocaleUtil.getLocalizedConfiguration(oldPrefLocaleCode))
super.attachBaseContext(newBase)
}
override fun onResume() {
val currentLocaleCode = LocaleStorage(this).getPreferredLocale()
if (oldPrefLocaleCode != currentLocaleCode) {
recreate() // locale is changed, restart the activity to update
oldPrefLocaleCode = currentLocaleCode
}
super.onResume()
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
tunnelService.cancelStatsJob() tunnelService.cancelStatsJob()
@@ -9,21 +9,6 @@ sealed class Route {
@Serializable @Serializable
data object Settings : Route() data object Settings : Route()
@Serializable
data object AutoTunnel : Route()
@Serializable
data object LocationDisclosure : Route()
@Serializable
data object Appearance : Route()
@Serializable
data object Display : Route()
@Serializable
data object Language : Route()
@Serializable @Serializable
data object Main : Route() data object Main : Route()
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -18,7 +17,7 @@ fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: Stri
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
) { ) {
Text(text, Modifier.weight(1f, false), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -16,6 +16,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -29,10 +31,12 @@ fun ExpandingRowListItem(
trailing: @Composable () -> Unit, trailing: @Composable () -> Unit,
isExpanded: Boolean, isExpanded: Boolean,
expanded: @Composable () -> Unit = {}, expanded: @Composable () -> Unit = {},
focusRequester: FocusRequester,
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequester)
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(30.dp)) .clip(RoundedCornerShape(30.dp))
.combinedClickable( .combinedClickable(
@@ -1,37 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SelectedLabel() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(id = R.string.selected),
modifier =
Modifier.padding(
horizontal = 24.dp.scaledWidth(),
vertical = 16.dp.scaledHeight(),
),
color =
MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
@@ -1,100 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import kotlin.let
@androidx.compose.runtime.Composable
fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, leadingIcon: ImageVector? = null, description: String? = null) {
val border: BorderStroke? =
if (selected) {
BorderStroke(
1.dp,
MaterialTheme.colorScheme.primary,
)
} else {
null
}
Card(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
shape = RoundedCornerShape(8.dp),
border = border,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Box(
modifier = Modifier.clickable { onClick() }
.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.padding(horizontal = 8.dp.scaledWidth(), vertical = 10.dp.scaledHeight())
.padding(end = 16.dp.scaledWidth()).padding(start = 8.dp.scaledWidth())
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp.scaledWidth()),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
16.dp.scaledWidth(),
),
verticalAlignment = Alignment.Companion.CenterVertically,
modifier = Modifier.padding(vertical = if (description == null) 10.dp.scaledHeight() else 0.dp),
) {
leadingIcon?.let {
Icon(
leadingIcon,
leadingIcon.name,
Modifier.size(iconSize),
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
)
}
Column {
Text(
title,
style = MaterialTheme.typography.titleMedium,
)
description?.let {
Text(
description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
}
}
@@ -1,18 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) {
Switch(
checked,
{ onClick(it) },
modifier.scale((52.dp.scaledHeight() / 52.dp)),
enabled = enabled,
)
}
@@ -1,63 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun SelectionItemButton(
leading: (@Composable () -> Unit)? = null,
buttonText: String,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
ripple: Boolean = true,
) {
Card(
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.clickable(
indication = if (ripple) ripple() else null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onClick() },
)
.height(56.dp.scaledHeight()),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxSize(),
) {
leading?.let {
it()
}
Text(
buttonText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
trailing?.let {
it()
}
}
}
}
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
data class SelectionItem(
val leadingIcon: ImageVector? = null,
val trailing: (@Composable () -> Unit)? = null,
val title: (@Composable () -> Unit),
val description: (@Composable () -> Unit)? = null,
val onClick: (() -> Unit)? = null,
val height: Int = 64,
)
@@ -1,86 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
items.mapIndexed { index, item ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(item.onClick?.let { Modifier.clickable { it() } } ?: Modifier)
.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp.scaledWidth())
.weight(4f, false)
.fillMaxWidth(),
) {
item.leadingIcon?.let { icon ->
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = if (item.leadingIcon != null) 16.dp.scaledWidth() else 0.dp)
.padding(vertical = if (item.description == null) 16.dp.scaledHeight() else 6.dp.scaledHeight()),
) {
item.title()
item.description?.let {
it()
}
}
}
item.trailing?.let {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.padding(end = 24.dp.scaledWidth(), start = 16.dp.scaledWidth())
.weight(1f),
) {
it()
}
}
}
}
if (index + 1 != items.size) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -10,19 +11,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle( fun ConfigurationToggle(
label: String, label: String,
enabled: Boolean = true, enabled: Boolean = true,
checked: Boolean, checked: Boolean,
padding: Dp,
onCheckChanged: (checked: Boolean) -> Unit, onCheckChanged: (checked: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
@@ -18,6 +18,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -30,6 +32,7 @@ fun SubmitConfigurationTextBox(
value: String?, value: String?,
label: String, label: String,
hint: String, hint: String,
focusRequester: FocusRequester,
isErrorValue: (value: String?) -> Boolean, isErrorValue: (value: String?) -> Boolean,
onSubmit: (value: String) -> Unit, onSubmit: (value: String) -> Unit,
keyboardOptions: KeyboardOptions = KeyboardOptions( keyboardOptions: KeyboardOptions = KeyboardOptions(
@@ -47,7 +50,8 @@ fun SubmitConfigurationTextBox(
OutlinedTextField( OutlinedTextField(
isError = isErrorValue(stateValue), isError = isErrorValue(stateValue),
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
value = stateValue, value = stateValue,
singleLine = true, singleLine = true,
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -1,21 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.label
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@Composable
fun GroupLabel(title: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
)
}
}
@@ -1,33 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.label
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun VersionLabel() {
val clipboardManager = LocalClipboardManager.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Text(
"${stringResource(R.string.version)}: ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
)
}
}
@@ -20,15 +20,15 @@ fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavIte
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
showBottomBar = bottomNavItems.any { showBottomBar = bottomNavItems.any {
navBackStackEntry?.isCurrentRoute(it.route::class) == true navBackStackEntry?.isCurrentRoute(it.route) == true
} }
if (showBottomBar) { if (showBottomBar) {
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
) { ) {
bottomNavItems.forEachIndexed { index, item -> bottomNavItems.forEach { item ->
val selected = navBackStackEntry.isCurrentRoute(item.route::class) val selected = navBackStackEntry.isCurrentRoute(item.route)
NavigationBarItem( NavigationBarItem(
selected = selected, selected = selected,
onClick = { onClick = {
@@ -5,11 +5,10 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import kotlin.reflect.KClass
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun <T : Route> NavBackStackEntry?.isCurrentRoute(cls: KClass<T>): Boolean { fun NavBackStackEntry?.isCurrentRoute(route: Route): Boolean {
return this?.destination?.hierarchy?.any { return this?.destination?.hierarchy?.any {
it.hasRoute(route = cls) it.hasRoute(route = route::class)
} == true } == true
} }
@@ -1,11 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.focus.FocusRequester
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
val LocalNavController = compositionLocalOf<NavHostController> { val LocalNavController = compositionLocalOf<NavHostController> {
error("NavController was not provided") error("NavController was not provided")
} }
val LocalFocusRequester = compositionLocalOf<FocusRequester> { error("FocusRequester is not provided") }
@@ -11,21 +11,19 @@ import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}, showBack: Boolean = true) { fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}) {
val navController = LocalNavController.current val navController = LocalNavController.current
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text(title) Text(title)
}, },
navigationIcon = { navigationIcon = {
if (showBack) { IconButton(onClick = { navController.popBackStack() }) {
IconButton(onClick = { navController.popBackStack() }) { val icon = Icons.AutoMirrored.Outlined.ArrowBack
val icon = Icons.AutoMirrored.Outlined.ArrowBack Icon(
Icon( imageVector = icon,
imageVector = icon, contentDescription = icon.name,
contentDescription = icon.name, )
)
}
} }
}, },
actions = { actions = {
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@@ -0,0 +1,27 @@
package com.zaneschepke.wireguardautotunnel.ui.common.screen
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.focusable()
.padding(),
) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
}
@@ -1,110 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.textbox
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomTextField(
value: String,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
label: @Composable () -> Unit,
containerColor: Color,
onValueChange: (value: String) -> Unit = {},
singleLine: Boolean = false,
placeholder: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
supportingText: @Composable (() -> Unit)? = null,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
isError: Boolean = false,
readOnly: Boolean = false,
enabled: Boolean = true,
) {
val interactionSource = remember { MutableInteractionSource() }
val space = " "
BasicTextField(
value = value,
textStyle = textStyle,
onValueChange = {
onValueChange(it)
},
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
readOnly = readOnly,
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
modifier = modifier,
interactionSource = interactionSource,
enabled = enabled,
singleLine = singleLine,
) {
OutlinedTextFieldDefaults.DecorationBox(
value = space + value,
innerTextField = {
if (value.isEmpty()) {
if (placeholder != null) {
placeholder()
}
}
it.invoke()
},
contentPadding = OutlinedTextFieldDefaults.contentPadding(top = 0.dp, bottom = 0.dp),
leadingIcon = leading,
trailingIcon = trailing,
singleLine = singleLine,
supportingText = supportingText,
colors = TextFieldDefaults.colors().copy(
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = containerColor,
focusedLabelColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
cursorColor = MaterialTheme.colorScheme.onSurface,
),
enabled = enabled,
label = label,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = placeholder,
container = {
OutlinedTextFieldDefaults.ContainerBox(
enabled,
isError = isError,
interactionSource,
colors = TextFieldDefaults.colors().copy(
errorContainerColor = containerColor,
disabledLabelColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = containerColor,
focusedIndicatorColor = MaterialTheme.colorScheme.onSurface,
focusedLabelColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = containerColor,
unfocusedContainerColor = containerColor,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
cursorColor = MaterialTheme.colorScheme.onSurface,
),
shape = RoundedCornerShape(8.dp),
focusedBorderThickness = 0.5.dp,
unfocusedBorderThickness = 0.5.dp,
)
},
)
}
}
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -60,7 +61,6 @@ import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
@@ -68,11 +68,11 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.Applicat
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun ConfigScreen(tunnelId: Int) { fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
val viewModel = hiltViewModel<ConfigViewModel, ConfigViewModel.ConfigViewModelFactory> { factory -> val viewModel = hiltViewModel<ConfigViewModel, ConfigViewModel.ConfigViewModelFactory> { factory ->
factory.create(tunnelId) factory.create(tunnelId)
} }
@@ -102,6 +102,18 @@ fun ConfigScreen(tunnelId: Int) {
} }
} }
LaunchedEffect(Unit) {
if (!uiState.loading && context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
kotlin.runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(2_000L) delay(2_000L)
viewModel.cleanUpUninstalledApps() viewModel.cleanUpUninstalledApps()
@@ -162,9 +174,6 @@ fun ConfigScreen(tunnelId: Int) {
} }
Scaffold( Scaffold(
topBar = {
TopNavBar(stringResource(R.string.edit_tunnel))
},
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
@@ -182,7 +191,7 @@ fun ConfigScreen(tunnelId: Int) {
} }
}, },
) { ) {
Column(Modifier.padding(it)) { Column {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
@@ -207,7 +216,7 @@ fun ConfigScreen(tunnelId: Int) {
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
} }
) )
.padding(bottom = 10.dp.scaledHeight()).padding(top = 24.dp.scaledHeight()), .padding(bottom = 10.dp),
) { ) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
@@ -224,7 +233,9 @@ fun ConfigScreen(tunnelId: Int) {
ConfigurationToggle( ConfigurationToggle(
stringResource(id = R.string.show_amnezia_properties), stringResource(id = R.string.show_amnezia_properties),
checked = derivedConfigType.value == ConfigType.AMNEZIA, checked = derivedConfigType.value == ConfigType.AMNEZIA,
padding = screenPadding,
onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD }, onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD },
modifier = Modifier.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.tunnelName, value = uiState.tunnelName,
@@ -347,7 +358,8 @@ fun ConfigScreen(tunnelId: Int) {
hint = stringResource(R.string.junk_packet_count).lowercase(), hint = stringResource(R.string.junk_packet_count).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMinSize, value = uiState.interfaceProxy.junkPacketMinSize,
@@ -360,7 +372,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMaxSize, value = uiState.interfaceProxy.junkPacketMaxSize,
@@ -373,7 +386,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketJunkSize, value = uiState.interfaceProxy.initPacketJunkSize,
@@ -383,7 +397,8 @@ fun ConfigScreen(tunnelId: Int) {
hint = stringResource(R.string.init_packet_junk_size).lowercase(), hint = stringResource(R.string.init_packet_junk_size).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketJunkSize, value = uiState.interfaceProxy.responsePacketJunkSize,
@@ -396,7 +411,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketMagicHeader, value = uiState.interfaceProxy.initPacketMagicHeader,
@@ -409,7 +425,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketMagicHeader, value = uiState.interfaceProxy.responsePacketMagicHeader,
@@ -422,7 +439,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.underloadPacketMagicHeader, value = uiState.interfaceProxy.underloadPacketMagicHeader,
@@ -435,7 +453,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
ConfigurationTextBox( ConfigurationTextBox(
value = uiState.interfaceProxy.transportPacketMagicHeader, value = uiState.interfaceProxy.transportPacketMagicHeader,
@@ -448,7 +467,8 @@ fun ConfigScreen(tunnelId: Int) {
).lowercase(), ).lowercase(),
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(focusRequester),
) )
} }
Row( Row(
@@ -519,15 +539,27 @@ fun ConfigScreen(tunnelId: Int) {
hint = stringResource(R.string.base64_key), hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
ConfigurationTextBox( OutlinedTextField(
modifier =
Modifier
.fillMaxWidth()
.clickable { showAuthPrompt = true },
value = peer.preSharedKey, value = peer.preSharedKey,
visualTransformation =
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated || peer.preSharedKey.isEmpty(),
onValueChange = { value -> onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value) viewModel.onPreSharedKeyChange(index, value)
}, },
label = { Text(stringResource(R.string.preshared_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.optional)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -1,11 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.content.Intent import android.annotation.SuppressLint
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -13,21 +10,19 @@ import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -35,11 +30,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@@ -49,7 +44,6 @@ import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
@@ -58,14 +52,15 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImpo
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isBatteryOptimizationsDisabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) { fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
@@ -75,25 +70,30 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
var isFabVisible by rememberSaveable { mutableStateOf(true) } var isFabVisible by rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val isRunningOnTv = remember { context.isRunningOnTv() }
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true }) NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
} }
val vpnActivity = val vpnActivityResultState =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
onResult = { onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true
}, },
) )
val batteryActivity =
rememberLauncherForActivityResult( LaunchedEffect(Unit) {
ActivityResultContracts.StartActivityForResult(), if (context.isRunningOnTv()) {
) { result: ActivityResult -> delay(Constants.FOCUS_REQUEST_DELAY)
viewModel.setBatteryOptimizeDisableShown() runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
} }
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = { val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
snackbar.showMessage( snackbar.showMessage(
@@ -116,7 +116,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
InfoDialog( InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false }, onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = { onAttest = {
selectedTunnel?.let { viewModel::onDelete } selectedTunnel?.let { viewModel.onDelete(it, context) }
showDeleteTunnelAlertDialog = false showDeleteTunnelAlertDialog = false
selectedTunnel = null selectedTunnel = null
}, },
@@ -126,35 +126,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
) )
} }
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
}
batteryActivity.launch(intent)
}
fun onAutoTunnelToggle() {
if (!uiState.generalState.isBatteryOptimizationDisableShown &&
!context.isBatteryOptimizationsDisabled() && !isRunningOnTv
) {
return requestBatteryOptimizationsDisabled()
}
val intent = if (!uiState.settings.isKernelEnabled) {
VpnService.prepare(context)
} else {
null
}
if (intent != null) return vpnActivity.launch(intent)
viewModel.onToggleAutoTunnel()
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) { fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
val intent = if (uiState.settings.isKernelEnabled) null else VpnService.prepare(context) val intent = if (uiState.settings.isKernelEnabled) null else VpnService.prepare(context)
if (intent != null) return vpnActivity.launch(intent) if (intent != null) return vpnActivityResultState.launch(intent)
if (!checked) viewModel.onTunnelStop(tunnel).also { return } if (!checked) viewModel.onTunnelStop(tunnel).also { return }
viewModel.onTunnelStart(tunnel, uiState.settings.isKernelEnabled) if (uiState.settings.isKernelEnabled) {
context.startTunnelBackground(tunnel.id)
} else {
viewModel.onTunnelStart(tunnel)
}
} }
Scaffold( Scaffold(
@@ -169,37 +149,16 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
}, },
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
if (!isRunningOnTv) { ScrollDismissFab({
ScrollDismissFab({ val icon = Icons.Filled.Add
val icon = Icons.Filled.Add Icon(
Icon( imageVector = icon,
imageVector = icon, contentDescription = icon.name,
contentDescription = icon.name, tint = MaterialTheme.colorScheme.onPrimary,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, isVisible = isFabVisible, onClick = {
showBottomSheet = true
})
}
},
topBar = {
if (isRunningOnTv) {
TopNavBar(
showBack = false,
title = stringResource(R.string.app_name),
trailing = {
IconButton(onClick = {
showBottomSheet = true
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
},
) )
} }, focusRequester, isVisible = isFabVisible, onClick = {
showBottomSheet = true
})
}, },
) { ) {
TunnelImportSheet( TunnelImportSheet(
@@ -215,10 +174,10 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
) )
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp.scaledHeight(), Alignment.Top), verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier
.fillMaxSize().padding(it) .fillMaxSize()
.overscroll(ScrollableDefaults.overscrollEffect()) .overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, uiState.tunnels.count()), state = rememberLazyListState(0, uiState.tunnels.count()),
@@ -230,11 +189,10 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
item { item {
GettingStartedLabel(onClick = { context.openWebUrl(it) }) GettingStartedLabel(onClick = { context.openWebUrl(it) })
} }
} else { }
if (uiState.settings.isAutoTunnelEnabled) {
item { item {
AutoTunnelRowItem(uiState, { AutoTunnelRowItem(uiState.settings, { viewModel.onToggleAutoTunnelingPause() }, focusRequester)
onAutoTunnelToggle()
})
} }
} }
items( items(
@@ -257,6 +215,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
onDelete = { showDeleteTunnelAlertDialog = true }, onDelete = { showDeleteTunnelAlertDialog = true },
onCopy = { viewModel.onCopyTunnel(tunnel) }, onCopy = { viewModel.onCopyTunnel(tunnel) },
onSwitchClick = { onTunnelToggle(it, tunnel) }, onSwitchClick = { onTunnelToggle(it, tunnel) },
focusRequester = focusRequester,
) )
} }
} }
@@ -30,24 +30,26 @@ import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
@HiltViewModel @HiltViewModel
class MainViewModel class MainViewModel
@Inject @Inject
constructor( constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val tunnelService: Provider<TunnelService>, val tunnelService: TunnelService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceManager: ServiceManager,
) : ViewModel() { ) : ViewModel() {
fun onDelete(tunnel: TunnelConfig) { private fun stopWatcherService(context: Context) {
ServiceManager.stopWatcherService(context)
}
fun onDelete(tunnel: TunnelConfig, context: Context) {
viewModelScope.launch { viewModelScope.launch {
val settings = appDataRepository.settings.getSettings() val settings = appDataRepository.settings.getSettings()
val isPrimary = tunnel.isPrimaryTunnel val isPrimary = tunnel.isPrimaryTunnel
if (appDataRepository.tunnels.count() == 1 || isPrimary) { if (appDataRepository.tunnels.count() == 1 || isPrimary) {
serviceManager.stopAutoTunnel() stopWatcherService(context)
resetTunnelSetting(settings) resetTunnelSetting(settings)
} }
appDataRepository.tunnels.delete(tunnel) appDataRepository.tunnels.delete(tunnel)
@@ -67,14 +69,14 @@ constructor(
appDataRepository.appState.setTunnelStatsExpanded(expanded) appDataRepository.appState.setTunnelStatsExpanded(expanded)
} }
fun onTunnelStart(tunnelConfig: TunnelConfig, background: Boolean) = viewModelScope.launch { fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch {
Timber.i("Starting tunnel ${tunnelConfig.name}") Timber.i("Starting tunnel ${tunnelConfig.name}")
tunnelService.get().startTunnel(tunnelConfig, background) tunnelService.startTunnel(tunnelConfig)
} }
fun onTunnelStop(tunnel: TunnelConfig) = viewModelScope.launch { fun onTunnelStop(tunnel: TunnelConfig) = viewModelScope.launch {
Timber.i("Stopping active tunnel") Timber.i("Stopping active tunnel")
tunnelService.get().stopTunnel(tunnel) tunnelService.stopTunnel(tunnel)
} }
private fun generateQrCodeDefaultName(config: String): String { private fun generateQrCodeDefaultName(config: String): String {
@@ -158,21 +160,6 @@ constructor(
} }
} }
fun onToggleAutoTunnel() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
val toggled = !settings.isAutoTunnelEnabled
if (toggled) {
serviceManager.startAutoTunnel(false)
} else {
serviceManager.stopAutoTunnel()
}
appDataRepository.settings.save(
settings.copy(
isAutoTunnelEnabled = toggled,
),
)
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) { private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip -> ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry } generateSequence { zip.nextEntry }
@@ -194,15 +181,18 @@ constructor(
} }
} }
fun setBatteryOptimizeDisableShown() = viewModelScope.launch {
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) { private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) {
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
saveTunnelConfigFromStream(stream, name) saveTunnelConfigFromStream(stream, name)
} }
fun onToggleAutoTunnelingPause() = viewModelScope.launch {
val settings = appDataRepository.settings.getSettings()
appDataRepository.settings.save(
settings.copy(isAutoTunnelPaused = !settings.isAutoTunnelPaused),
)
}
private fun saveTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { private fun saveTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig) appDataRepository.tunnels.save(tunnelConfig)
} }
@@ -4,27 +4,43 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.text.buildAnnotatedString
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable @Composable
fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) { fun AutoTunnelRowItem(settings: Settings, onToggle: () -> Unit, focusRequester: FocusRequester) {
val context = LocalContext.current val context = LocalContext.current
val itemFocusRequester = remember { FocusRequester() } val itemFocusRequester = remember { FocusRequester() }
val autoTunnelingLabel =
buildAnnotatedString {
append(stringResource(id = R.string.auto_tunneling))
append(": ")
if (settings.isAutoTunnelPaused) {
append(
stringResource(id = R.string.paused),
)
} else {
append(
stringResource(id = R.string.active),
)
}
}
ExpandingRowListItem( ExpandingRowListItem(
leading = { leading = {
val icon = Icons.Rounded.Bolt val icon = Icons.Rounded.Bolt
@@ -33,23 +49,23 @@ fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) {
icon.name, icon.name,
modifier = modifier =
Modifier Modifier
.size(16.dp.scaledHeight()).scale(1.5f), .size(iconSize).scale(1.5f),
tint = tint =
if (!appUiState.autoTunnelActive) { if (settings.isAutoTunnelPaused) {
Color.Gray Color.Gray
} else { } else {
SilverTree SilverTree
}, },
) )
}, },
text = stringResource(R.string.auto_tunneling), text = autoTunnelingLabel.text,
trailing = { trailing = {
ScaledSwitch( TextButton(
appUiState.settings.isAutoTunnelEnabled, modifier = Modifier.focusRequester(itemFocusRequester),
onClick = { onClick = { onToggle() },
onToggle() ) {
}, Text(stringResource(id = if (settings.isAutoTunnelPaused) R.string.resume else R.string.pause))
) }
}, },
onClick = { onClick = {
if (context.isRunningOnTv()) { if (context.isRunningOnTv()) {
@@ -57,5 +73,6 @@ fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) {
} }
}, },
isExpanded = false, isExpanded = false,
focusRequester = focusRequester,
) )
} }
@@ -9,16 +9,19 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
fun ScrollDismissFab(icon: @Composable () -> Unit, isVisible: Boolean, onClick: () -> Unit) { fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequester, isVisible: Boolean, onClick: () -> Unit) {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }), exit = slideOutVertically(targetOffsetY = { it * 2 }),
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequester)
.focusGroup(), .focusGroup(),
) { ) {
FloatingActionButton( FloatingActionButton(
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.rounded.Smartphone
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -22,18 +23,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable @Composable
fun TunnelRowItem( fun TunnelRowItem(
@@ -47,6 +46,7 @@ fun TunnelRowItem(
onCopy: () -> Unit, onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSwitchClick: (checked: Boolean) -> Unit, onSwitchClick: (checked: Boolean) -> Unit,
focusRequester: FocusRequester,
) { ) {
val leadingIconColor = if (!isActive) Color.Gray else vpnState.statistics.asColor() val leadingIconColor = if (!isActive) Color.Gray else vpnState.statistics.asColor()
val context = LocalContext.current val context = LocalContext.current
@@ -69,7 +69,7 @@ fun TunnelRowItem(
icon, icon,
icon.name, icon.name,
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.size(16.dp.scaledHeight()), modifier = Modifier.size(iconSize),
) )
}, },
text = tunnel.name, text = tunnel.name,
@@ -89,6 +89,7 @@ fun TunnelRowItem(
}, },
isExpanded = expanded && isActive, isExpanded = expanded && isActive,
expanded = { if (isActive && expanded) TunnelStatisticsRow(vpnState.statistics, tunnel) }, expanded = { if (isActive && expanded) TunnelStatisticsRow(vpnState.statistics, tunnel) },
focusRequester = focusRequester,
trailing = { trailing = {
if ( if (
isSelected && isSelected &&
@@ -142,6 +143,7 @@ fun TunnelRowItem(
) )
} }
IconButton( IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = { onClick = {
if (isActive) { if (isActive) {
onClick() onClick()
@@ -179,17 +181,21 @@ fun TunnelRowItem(
icon.name, icon.name,
) )
} }
ScaledSwitch( Switch(
modifier = Modifier.focusRequester(itemFocusRequester), modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive, checked = isActive,
onClick = onSwitchClick, onCheckedChange = { checked ->
onSwitchClick(checked)
},
) )
} }
} else { } else {
ScaledSwitch( Switch(
modifier = Modifier.focusRequester(itemFocusRequester), modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive, checked = isActive,
onClick = onSwitchClick, onCheckedChange = { checked ->
onSwitchClick(checked)
},
) )
} }
} }
@@ -46,18 +46,14 @@ fun TunnelStatisticsRow(statistics: TunnelStatistics?, tunnelConfig: TunnelConfi
Column( Column(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(stringResource(R.string.peer).lowercase() + ": $peerId", style = MaterialTheme.typography.bodySmall)
stringResource(R.string.peer).lowercase() + ": $peerId", Text("tx: $peerTxMB MB", style = MaterialTheme.typography.bodySmall)
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Text("tx: $peerTxMB MB", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline)
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text(stringResource(R.string.handshake) + ": $handshake", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) Text(stringResource(R.string.handshake) + ": $handshake", style = MaterialTheme.typography.bodySmall)
Text("rx: $peerRxMB MB", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) Text("rx: $peerRxMB MB", style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@@ -1,23 +1,33 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.options package com.zaneschepke.wireguardautotunnel.ui.screens.options
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -27,171 +37,298 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiState: AppUiState, tunnelId: Int) { fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), focusRequester: FocusRequester, appUiState: AppUiState, tunnelId: Int) {
val scrollState = rememberScrollState()
val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val config = appUiState.tunnels.first { it.id == tunnelId } val config = appUiState.tunnels.first { it.id == tunnelId }
val interactionSource = remember { MutableInteractionSource() }
val focusManager = LocalFocusManager.current
val screenPadding = 5.dp
val fillMaxWidth = .85f
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
LaunchedEffect(config.tunnelNetworks) { LaunchedEffect(Unit) {
currentText = "" if (context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
kotlin.runCatching {
focusRequester.requestFocus()
}.onFailure {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
} }
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
optionsViewModel.onSaveRunSSID(currentText, config)
currentText = ""
}
}
Scaffold( Scaffold(
topBar = { floatingActionButton = {
TopNavBar(config.name, trailing = { ScrollDismissFab(icon = {
IconButton(onClick = { val icon = Icons.Filled.Edit
navController.navigate( Icon(
Route.Config(config.id), imageVector = icon,
) contentDescription = icon.name,
}) { tint = MaterialTheme.colorScheme.onPrimary,
val icon = Icons.Outlined.Edit )
Icon( }, focusRequester, isVisible = true, onClick = {
imageVector = icon, navController.navigate(
contentDescription = icon.name, Route.Config(config.id),
) )
}
}) })
}, },
) { ) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(it) .verticalScroll(scrollState)
.padding(top = 24.dp.scaledHeight()) .clickable(
.padding(horizontal = 24.dp.scaledWidth()), indication = null,
interactionSource = interactionSource,
) {
focusManager.clearFocus()
},
) { ) {
GroupLabel(stringResource(R.string.auto_tunneling)) Surface(
SurfaceSelectionGroupButton( tonalElevation = 2.dp,
listOf( shadowElevation = 2.dp,
SelectionItem( shape = RoundedCornerShape(12.dp),
Icons.Outlined.Star, color = MaterialTheme.colorScheme.surface,
title = { modifier =
Text( (
stringResource(R.string.primary_tunnel), if (context.isRunningOnTv()) {
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), Modifier
) .height(IntrinsicSize.Min)
}, .fillMaxWidth(fillMaxWidth)
description = { .padding(top = 10.dp)
Text( } else {
stringResource(R.string.set_primary_tunnel), Modifier
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline), .fillMaxWidth(fillMaxWidth)
) .padding(top = 20.dp)
}, }
trailing = { )
ScaledSwitch( .padding(bottom = 10.dp),
config.isPrimaryTunnel, ) {
onClick = { optionsViewModel.onTogglePrimaryTunnel(config) }, Column(
) horizontalAlignment = Alignment.Start,
}, verticalArrangement = Arrangement.Top,
onClick = { optionsViewModel.onTogglePrimaryTunnel(config) }, modifier = Modifier.padding(15.dp),
), ) {
SelectionItem( SectionTitle(
Icons.Outlined.PhoneAndroid, title = stringResource(id = R.string.general),
title = { Text(stringResource(R.string.mobile_tunnel), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) }, padding = screenPadding,
description = { )
Text( ConfigurationToggle(
stringResource(R.string.mobile_data_tunnel), stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline), enabled = true,
) checked = config.isPrimaryTunnel,
}, modifier =
trailing = { Modifier
ScaledSwitch( .focusRequester(focusRequester),
config.isMobileDataTunnel, padding = screenPadding,
onClick = { optionsViewModel.onToggleIsMobileDataTunnel(config) }, onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel(config) },
) )
}, }
onClick = { optionsViewModel.onToggleIsMobileDataTunnel(config) }, }
), Surface(
SelectionItem( tonalElevation = 2.dp,
Icons.Outlined.NetworkPing, shadowElevation = 2.dp,
title = { shape = RoundedCornerShape(12.dp),
Text( color = MaterialTheme.colorScheme.surface,
stringResource(R.string.restart_on_ping), modifier =
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), (
) if (context.isRunningOnTv()) {
}, Modifier
trailing = { .height(IntrinsicSize.Min)
ScaledSwitch( .fillMaxWidth(fillMaxWidth)
checked = config.isPingEnabled, .padding(top = 10.dp)
onClick = { optionsViewModel.onToggleRestartOnPing(config) }, } else {
) Modifier
}, .fillMaxWidth(fillMaxWidth)
onClick = { optionsViewModel.onToggleRestartOnPing(config) }, .padding(top = 20.dp)
), }
SelectionItem( )
title = { .padding(bottom = 10.dp),
Row( ) {
verticalAlignment = Alignment.CenterVertically, Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()), horizontalAlignment = Alignment.Start,
) { verticalArrangement = Arrangement.Top,
Row( modifier = Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically, ) {
modifier = Modifier SectionTitle(
.weight(4f, false) title = stringResource(id = R.string.auto_tunneling),
.fillMaxWidth(), padding = screenPadding,
) { )
val icon = Icons.Outlined.Security ConfigurationToggle(
Icon( stringResource(R.string.mobile_data_tunnel),
icon, enabled = true,
icon.name, checked = config.isMobileDataTunnel,
modifier = Modifier.size(iconSize), padding = screenPadding,
) onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel(config) },
Column( )
horizontalAlignment = Alignment.Start, Column {
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), FlowRow(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(start = 16.dp.scaledWidth()) .padding(screenPadding)
.padding(vertical = 6.dp.scaledHeight()), .fillMaxWidth(),
) { horizontalArrangement = Arrangement.spacedBy(5.dp),
Text( ) {
stringResource(R.string.use_tunnel_on_wifi_name), config.tunnelNetworks.forEach { ssid ->
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ClickableIconButton(
onClick = {
if (context.isRunningOnTv()) {
focusRequester.requestFocus()
optionsViewModel.onDeleteRunSSID(ssid, config)
}
},
onIconClick = {
if (context.isRunningOnTv()) focusRequester.requestFocus()
optionsViewModel.onDeleteRunSSID(ssid, config)
},
text = ssid,
icon = Icons.Filled.Close,
enabled = true,
)
}
if (config.tunnelNetworks.isEmpty()) {
Text(
stringResource(R.string.no_wifi_names_configured),
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
OutlinedTextField(
enabled = true,
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) },
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
modifier =
Modifier
.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
),
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.save_changes),
tint = MaterialTheme.colorScheme.primary,
) )
} }
} }
} },
}, )
description = { ConfigurationToggle(
TrustedNetworkTextBox( stringResource(R.string.restart_on_ping),
config.tunnelNetworks, enabled = !appUiState.settings.isPingEnabled,
onDelete = { optionsViewModel.onDeleteRunSSID(it, config) }, checked = config.isPingEnabled || appUiState.settings.isPingEnabled,
currentText = currentText, padding = screenPadding,
onSave = { optionsViewModel.onSaveRunSSID(it, config) }, onCheckChanged = { optionsViewModel.onToggleRestartOnPing(config) },
onValueChange = { currentText = it }, )
supporting = { if (config.isPingEnabled || appUiState.settings.isPingEnabled) {
if (appUiState.settings.isWildcardsEnabled) { SubmitConfigurationTextBox(
WildcardsLabel() config.pingIp,
} stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
focusRequester,
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingIp = it.ifBlank { null }),
)
}, },
) )
}, fun isSecondsError(seconds: String?): Boolean {
), return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } ?: false
), }
) SubmitConfigurationTextBox(
config.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
focusRequester,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
SubmitConfigurationTextBox(
config.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
focusRequester,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
}
}
}
}
} }
} }
} }
@@ -32,7 +32,6 @@ constructor(
} }
fun onSaveRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch { fun onSaveRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch {
if (ssid.isBlank()) return@launch
val trimmed = ssid.trim() val trimmed = ssid.trim()
val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed) val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed)
@@ -1,61 +1,87 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.Manifest
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewQuilt import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.AppShortcut import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material3.Icon
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material3.IconButton
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Pin
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.VpnLock
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalFocusRequester
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.theme.topPadding import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDisclosure
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import xyz.teamgravity.pin_lock_compose.PinManager import xyz.teamgravity.pin_lock_compose.PinManager
@@ -64,22 +90,157 @@ import xyz.teamgravity.pin_lock_compose.PinManager
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
) )
@Composable @Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, uiState: AppUiState) { fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, uiState: AppUiState, focusRequester: FocusRequester) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
val rootFocusRequester = LocalFocusRequester.current
val isRunningOnTv = remember { context.isRunningOnTv() }
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isRunningOnTv = context.isRunningOnTv()
val settingsUiState by viewModel.uiState.collectAsStateWithLifecycle()
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
val didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
val screenPadding = 5.dp
val fillMaxWidth = .85f
LaunchedEffect(uiState.settings.trustedNetworkSSIDs) {
currentText = ""
}
val startForResult =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
if (result.resultCode == RESULT_OK) {
result.data
// Handle the Intent
}
viewModel.setBatteryOptimizeDisableShown()
}
val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
viewModel.onToggleAutoTunnel(context)
} else {
showVpnPermissionDialog = true
}
},
)
fun isBatteryOptimizationsDisabled(): Boolean {
val pm = context.getSystemService(POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
}
startForResult.launch(intent)
}
fun handleAutoTunnelToggle() {
if (!uiState.generalState.isBatteryOptimizationDisableShown &&
!isBatteryOptimizationsDisabled() && !context.isRunningOnTv()
) {
return requestBatteryOptimizationsDisabled()
}
val intent = if (!uiState.settings.isKernelEnabled) {
VpnService.prepare(context)
} else {
null
}
if (intent != null) return vpnActivityResultState.launch(intent)
viewModel.onToggleAutoTunnel(context)
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
viewModel.onSaveTrustedSSID(currentText)
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted =
if (!fineLocationState.status.isGranted) {
false
} else {
viewModel.setLocationDisclosureShown()
true
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
isRunningOnTv &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
if (!uiState.generalState.isLocationDisclosureShown) {
BackgroundLocationDisclosure(
onDismiss = { viewModel.setLocationDisclosureShown() },
onAttest = {
context.launchAppSettings()
viewModel.setLocationDisclosureShown()
},
scrollState,
focusRequester,
)
return
}
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showVpnPermissionDialog = false },
onAttest = { handleAutoTunnelToggle() },
)
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
if (showAuthPrompt) { if (showAuthPrompt) {
AuthorizationPrompt( AuthorizationPrompt(
onSuccess = { onSuccess = {
showAuthPrompt = false showAuthPrompt = false
viewModel.exportAllConfigs(context) viewModel.exportAllConfigs()
}, },
onError = { _ -> onError = { _ ->
showAuthPrompt = false showAuthPrompt = false
@@ -96,238 +257,351 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
) )
} }
fun onAutoTunnelWifiChecked() {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
}
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize() .fillMaxSize()
.padding(top = topPadding) .verticalScroll(scrollState)
.padding(bottom = 40.dp.scaledHeight()) .clickable(
.padding(horizontal = 24.dp.scaledWidth()) indication = null,
.then( interactionSource = interactionSource,
if (!isRunningOnTv) { ) {
Modifier.clickable( focusManager.clearFocus()
indication = null, },
interactionSource = interactionSource, ) {
) { Surface(
focusManager.clearFocus() tonalElevation = 2.dp,
} shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (isRunningOnTv) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else { } else {
Modifier Modifier
}, .fillMaxWidth(fillMaxWidth)
), .padding(top = 20.dp)
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Bolt,
title = { Text(stringResource(R.string.auto_tunneling), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
description = {
Text(
stringResource(R.string.on_demand_rules),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
if (!uiState.generalState.isLocationDisclosureShown) return@SelectionItem navController.navigate(Route.LocationDisclosure)
navController.navigate(Route.AutoTunnel)
},
trailing = {
ForwardButton(Modifier.focusable().focusRequester(rootFocusRequester)) { navController.navigate(Route.AutoTunnel) }
},
),
),
)
SurfaceSelectionGroupButton(
buildList {
if (!isRunningOnTv) {
addAll(
listOf(
SelectionItem(
Icons.Filled.AppShortcut,
{
ScaledSwitch(
uiState.settings.isShortcutsEnabled,
onClick = { viewModel.onToggleShortcutsEnabled() },
)
},
title = {
Text(
stringResource(R.string.enabled_app_shortcuts),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.onToggleShortcutsEnabled() },
),
SelectionItem(
Icons.Outlined.VpnLock,
{
ScaledSwitch(
enabled = !(
(
uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnEthernetEnabled ||
uiState.settings.isTunnelOnMobileDataEnabled
) &&
uiState.settings.isAutoTunnelEnabled
),
onClick = { viewModel.onToggleAlwaysOnVPN() },
checked = uiState.settings.isAlwaysOnVpnEnabled,
)
},
title = {
Text(
stringResource(R.string.always_on_vpn_support),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.onToggleAlwaysOnVPN() },
),
SelectionItem(
Icons.Outlined.AdminPanelSettings,
title = {
Text(
stringResource(R.string.kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
context.launchVpnSettings()
},
trailing = {
ForwardButton { context.launchVpnSettings() }
},
),
),
)
} }
add(
SelectionItem(
Icons.Outlined.Restore,
{
ScaledSwitch(
uiState.settings.isRestoreOnBootEnabled,
onClick = { viewModel.onToggleRestartAtBoot() },
)
},
title = {
Text(
stringResource(R.string.restart_at_boot),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.onToggleRestartAtBoot() },
),
) )
}, .padding(bottom = 10.dp),
) ) {
Column(
SurfaceSelectionGroupButton( horizontalAlignment = Alignment.Start,
listOf( verticalArrangement = Arrangement.Top,
SelectionItem( modifier = Modifier.padding(15.dp),
Icons.AutoMirrored.Outlined.ViewQuilt, ) {
title = { Text(stringResource(R.string.appearance), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) }, SectionTitle(
onClick = { title = stringResource(id = R.string.auto_tunneling),
navController.navigate(Route.Appearance) padding = screenPadding,
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { checked ->
if (!checked || settingsUiState.isRooted) viewModel.onToggleTunnelOnWifi().also { return@ConfigurationToggle }
onAutoTunnelWifiChecked()
}, },
trailing = { modifier =
ForwardButton { navController.navigate(Route.Appearance) } if (uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier
.focusRequester(focusRequester)
}, },
), )
SelectionItem( if (uiState.settings.isTunnelOnWifiEnabled) {
Icons.Outlined.Notifications, Column {
title = { Text(stringResource(R.string.notifications), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) }, FlowRow(
onClick = { modifier =
context.launchNotificationSettings() Modifier
}, .padding(screenPadding)
trailing = { .fillMaxWidth(),
ForwardButton { context.launchNotificationSettings() } horizontalArrangement = Arrangement.spacedBy(5.dp),
}, ) {
), uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
SelectionItem( ClickableIconButton(
Icons.Outlined.Pin, onClick = {
title = { if (isRunningOnTv) {
Text( focusRequester.requestFocus()
stringResource(R.string.enable_app_lock), viewModel.onDeleteTrustedSSID(ssid)
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), }
) },
}, onIconClick = {
trailing = { if (isRunningOnTv) focusRequester.requestFocus()
ScaledSwitch( viewModel.onDeleteTrustedSSID(ssid)
uiState.generalState.isPinLockEnabled, },
onClick = { text = ssid,
if (uiState.generalState.isPinLockEnabled) { icon = Icons.Filled.Close,
appViewModel.onPinLockDisabled() )
} else { }
PinManager.initialize(context) if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
navController.navigate(Route.Lock) Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
OutlinedTextField(
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier
.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
),
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id =
R.string
.trusted_ssid_empty_description,
)
} else {
stringResource(
id =
R.string
.trusted_ssid_value_description,
)
},
tint = MaterialTheme.colorScheme.primary,
)
}
} }
}, },
) )
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.restart_on_ping),
checked = uiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleRestartOnPing() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(
if (!uiState.settings.isAutoTunnelEnabled) {
Modifier
} else {
Modifier.focusRequester(
focusRequester,
)
}
)
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
handleAutoTunnelToggle()
},
) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.backend),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) ||
!settingsUiState.isKernelAvailable
),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleKernelMode()
}, },
onClick = { )
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = {
viewModel.onRequestRoot()
},
) {
Text(stringResource(R.string.request_root))
}
}
}
}
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding,
)
if (!isRunningOnTv) {
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !(
(
uiState.settings.isTunnelOnWifiEnabled ||
uiState.settings.isTunnelOnEthernetEnabled ||
uiState.settings.isTunnelOnMobileDataEnabled
) &&
uiState.settings.isAutoTunnelEnabled
),
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
}
ConfigurationToggle(
stringResource(R.string.restart_at_boot),
enabled = true,
checked = uiState.settings.isRestoreOnBootEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleRestartAtBoot()
},
)
ConfigurationToggle(
stringResource(R.string.enable_app_lock),
enabled = true,
checked = uiState.generalState.isPinLockEnabled,
padding = screenPadding,
onCheckChanged = {
if (uiState.generalState.isPinLockEnabled) { if (uiState.generalState.isPinLockEnabled) {
appViewModel.onPinLockDisabled() appViewModel.onPinLockDisabled()
} else { } else {
PinManager.initialize(context) // TODO may want to show a dialog before proceeding in the future
PinManager.initialize(WireGuardAutoTunnel.instance)
navController.navigate(Route.Lock) navController.navigate(Route.Lock)
} }
}, },
), )
), if (!isRunningOnTv) {
) Row(
verticalAlignment = Alignment.CenterVertically,
if (!isRunningOnTv) { modifier =
SurfaceSelectionGroupButton( Modifier
listOf( .fillMaxSize()
SelectionItem( .padding(top = 5.dp),
Icons.Outlined.Code, horizontalArrangement = Arrangement.Center,
title = { Text(stringResource(R.string.kernel), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) }, ) {
description = { TextButton(
Text( enabled = !didExportFiles,
stringResource(R.string.use_kernel), onClick = {
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
) showAuthPrompt = true
}, },
trailing = { ) {
ScaledSwitch( Text(stringResource(R.string.export_configs))
uiState.settings.isKernelEnabled, }
onClick = { viewModel.onToggleKernelMode() }, }
enabled = !( }
uiState.settings.isAutoTunnelEnabled || }
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP)
),
)
},
onClick = {
viewModel.onToggleKernelMode()
},
),
),
)
}
if (!isRunningOnTv) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.FolderZip,
title = {
Text(
stringResource(R.string.export_configs),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
if (uiState.tunnels.isEmpty()) return@SelectionItem context.showToast(R.string.tunnel_required)
showAuthPrompt = true
},
),
),
)
} }
} }
} }
@@ -0,0 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
data class SettingsUiState(
val isRooted: Boolean = false,
val isKernelAvailable: Boolean = false,
)
@@ -1,7 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.content.Context import android.content.Context
import androidx.core.content.FileProvider import android.location.LocationManager
import androidx.core.location.LocationManagerCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.backend.WgQuickBackend
@@ -9,19 +10,22 @@ import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.AppShell
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.Instant import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@@ -30,18 +34,94 @@ class SettingsViewModel
@Inject @Inject
constructor( constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@AppShell private val rootShell: Provider<RootShell>, private val rootShell: Provider<RootShell>,
private val fileUtils: FileUtils, private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState = _uiState.onStart {
_uiState.update {
it.copy(isKernelAvailable = isKernelSupported(), isRooted = isRooted())
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
private val settings = appDataRepository.settings.getSettingsFlow() private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) .stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
val trimmed = ssid.trim()
with(settings.value) {
if (!trustedNetworkSSIDs.contains(trimmed)) {
this.trustedNetworkSSIDs.add(ssid)
appDataRepository.settings.save(this)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
fun setLocationDisclosureShown() = viewModelScope.launch { fun setLocationDisclosureShown() = viewModelScope.launch {
appDataRepository.appState.setLocationDisclosureShown(true) appDataRepository.appState.setLocationDisclosureShown(true)
} }
fun setBatteryOptimizeDisableShown() = viewModelScope.launch {
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnMobileDataEnabled = !this.isTunnelOnMobileDataEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
trustedNetworkSSIDs = (this.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
private fun exportTunnels(files: List<File>) = viewModelScope.launch {
fileUtils.saveFilesToZip(files).onSuccess {
SnackbarController.showMessage(StringValue.StringResource(R.string.exported_configs_message))
}.onFailure {
SnackbarController.showMessage(StringValue.StringResource(R.string.export_configs_failed))
}
}
fun onToggleAutoTunnel(context: Context) = viewModelScope.launch {
with(settings.value) {
var isAutoTunnelPaused = this.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(context)
} else {
ServiceManager.startWatcherService(context)
isAutoTunnelPaused = false
}
appDataRepository.settings.save(
copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
),
)
}
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch { fun onToggleAlwaysOnVPN() = viewModelScope.launch {
with(settings.value) { with(settings.value) {
appDataRepository.settings.save( appDataRepository.settings.save(
@@ -52,6 +132,24 @@ constructor(
} }
} }
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled,
),
)
}
}
fun isLocationEnabled(context: Context): Boolean {
val locationManager =
context.getSystemService(
Context.LOCATION_SERVICE,
) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun onToggleShortcutsEnabled() = viewModelScope.launch { fun onToggleShortcutsEnabled() = viewModelScope.launch {
with(settings.value) { with(settings.value) {
appDataRepository.settings.save( appDataRepository.settings.save(
@@ -72,11 +170,33 @@ constructor(
} }
} }
fun onToggleTunnelOnWifi() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleAmnezia() = viewModelScope.launch {
with(settings.value) {
if (isKernelEnabled) {
saveKernelMode(false)
}
appDataRepository.settings.save(
copy(
isAmneziaEnabled = !isAmneziaEnabled,
),
)
}
}
fun onToggleKernelMode() = viewModelScope.launch { fun onToggleKernelMode() = viewModelScope.launch {
with(settings.value) { with(settings.value) {
if (!isKernelEnabled) { if (!isKernelEnabled) {
requestRoot().onSuccess { requestRoot().onSuccess {
if (!isKernelSupported()) return@onSuccess SnackbarController.showMessage(StringValue.StringResource(R.string.kernel_not_supported))
appDataRepository.settings.save( appDataRepository.settings.save(
copy( copy(
isKernelEnabled = true, isKernelEnabled = true,
@@ -90,6 +210,16 @@ constructor(
} }
} }
fun onToggleRestartOnPing() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
private suspend fun isKernelSupported(): Boolean { private suspend fun isKernelSupported(): Boolean {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
WgQuickBackend.hasKernelSupport() WgQuickBackend.hasKernelSupport()
@@ -106,6 +236,17 @@ constructor(
} }
} }
private suspend fun isRooted(): Boolean {
return try {
withContext(ioDispatcher) {
rootShell.get().start()
}
true
} catch (_: Exception) {
false
}
}
private suspend fun requestRoot(): Result<Unit> { private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
kotlin.runCatching { kotlin.runCatching {
@@ -117,16 +258,16 @@ constructor(
} }
} }
fun exportAllConfigs(context: Context) = viewModelScope.launch { fun onRequestRoot() = viewModelScope.launch {
requestRoot()
}
fun exportAllConfigs() = viewModelScope.launch {
kotlin.runCatching { kotlin.runCatching {
val shareFile = fileUtils.createNewShareFile("wg-export_${Instant.now().epochSecond}.zip")
val tunnels = appDataRepository.tunnels.getAll() val tunnels = appDataRepository.tunnels.getAll()
val wgFiles = fileUtils.createWgFiles(tunnels) val wgFiles = fileUtils.createWgFiles(tunnels)
val amFiles = fileUtils.createAmFiles(tunnels) val amFiles = fileUtils.createAmFiles(tunnels)
val allFiles = wgFiles + amFiles exportTunnels(wgFiles + amFiles)
fileUtils.zipAll(shareFile, allFiles)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri)
} }
} }
} }
@@ -1,72 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun AppearanceScreen() {
val navController = LocalNavController.current
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.appearance))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Translate,
title = { Text(stringResource(R.string.language), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Language) },
trailing = {
ForwardButton { navController.navigate(Route.Language) }
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Contrast,
title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Display) },
trailing = {
ForwardButton { navController.navigate(Route.Display) }
},
),
),
)
}
}
}
@@ -1,63 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltViewModel()) {
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.display_theme))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
IconSurfaceButton(
title = stringResource(R.string.automatic),
onClick = {
viewModel.onThemeChange(Theme.AUTOMATIC)
},
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
)
IconSurfaceButton(
title = stringResource(R.string.light),
onClick = { viewModel.onThemeChange(Theme.LIGHT) },
selected = appUiState.generalState.theme == Theme.LIGHT,
)
IconSurfaceButton(
title = stringResource(R.string.dark),
onClick = { viewModel.onThemeChange(Theme.DARK) },
selected = appUiState.generalState.theme == Theme.DARK,
)
IconSurfaceButton(
title = stringResource(R.string.dynamic),
onClick = { viewModel.onThemeChange(Theme.DYNAMIC) },
selected = appUiState.generalState.theme == Theme.DYNAMIC,
)
}
}
}
@@ -1,21 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DisplayViewModel
@Inject
constructor(
private val appStateRepository: AppStateRepository,
) : ViewModel() {
fun onThemeChange(theme: Theme) = viewModelScope.launch {
appStateRepository.setTheme(theme)
}
}
@@ -1,114 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.datastore.LocaleStorage
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SelectedLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.navigateAndForget
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import timber.log.Timber
import java.text.Collator
import java.util.Locale
@Composable
fun LanguageScreen(localeStorage: LocaleStorage) {
val navController = LocalNavController.current
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
val currentLocale = remember { mutableStateOf(LocaleUtil.OPTION_PHONE_LANGUAGE) }
val locales = LocaleUtil.supportedLocales.map {
val tag = it.replace("_", "-")
Locale.forLanguageTag(tag)
}
val sortedLocales =
remember(locales) {
locales.sortedWith(compareBy(collator) { it.getDisplayName(it) }).toList()
}
LaunchedEffect(Unit) {
currentLocale.value = localeStorage.getPreferredLocale()
}
fun onChangeLocale(locale: String) {
Timber.d("Setting preferred locale: $locale")
localeStorage.setPreferredLocale(locale)
LocaleUtil.applyLocalizedContext(context, locale)
navController.navigateAndForget(Route.Main)
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.language))
},
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize().padding(it)
.padding(horizontal = 24.dp.scaledWidth()).windowInsetsPadding(WindowInsets.navigationBars),
) {
item {
Box(modifier = Modifier.padding(top = 24.dp.scaledHeight())) {
SelectionItemButton(
buttonText = stringResource(R.string.automatic),
onClick = {
onChangeLocale(LocaleUtil.OPTION_PHONE_LANGUAGE)
},
trailing = {
if (currentLocale.value == LocaleUtil.OPTION_PHONE_LANGUAGE) {
SelectedLabel()
}
},
ripple = false,
)
}
}
items(sortedLocales, key = { it }) { locale ->
SelectionItemButton(
buttonText = locale.getDisplayLanguage(locale).capitalize(locale) +
if (locale.toLanguageTag().contains("-")) " (${locale.getDisplayCountry(locale).capitalize(locale)})" else "",
onClick = {
onChangeLocale(locale.toLanguageTag())
},
trailing = {
if (locale.toLanguageTag() == currentLocale.value) {
SelectedLabel()
}
},
ripple = false,
)
}
}
}
}
@@ -1,330 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel
import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@OptIn(ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AutoTunnelViewModel = hiltViewModel()) {
val context = LocalContext.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fineLocationState.status.isGranted
}
fun onAutoTunnelWifiChecked() {
if (uiState.settings.isTunnelOnWifiEnabled) viewModel.onToggleTunnelOnWifi().also { return }
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
context.isLocationServicesEnabled() ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (context.isRunningOnTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted()
} else {
val backgroundLocationState = rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted = backgroundLocationState.status.isGranted
}
}
LaunchedEffect(uiState.settings.trustedNetworkSSIDs) {
currentText = ""
}
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showLocationServicesAlertDialog = false },
onAttest = {
viewModel.onToggleTunnelOnWifi()
},
)
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
Scaffold(
contentWindowInsets = WindowInsets(0.dp),
topBar = {
TopNavBar(stringResource(R.string.auto_tunneling))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
buildList {
addAll(
listOf(
SelectionItem(
Icons.Outlined.Wifi,
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled,
onClick = {
if (uiState.settings.isWifiNameByShellEnabled) viewModel.onToggleTunnelOnWifi().also { return@ScaledSwitch }
onAutoTunnelWifiChecked()
},
)
},
onClick = {
if (uiState.settings.isWifiNameByShellEnabled) viewModel.onToggleTunnelOnWifi().also { return@SelectionItem }
onAutoTunnelWifiChecked()
},
),
SelectionItem(
Icons.Outlined.Code,
title = {
Text(
stringResource(R.string.wifi_name_via_shell),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.use_root_shell_for_wifi),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.settings.isWifiNameByShellEnabled,
onClick = {
viewModel.onRootShellWifiToggle()
},
)
},
onClick = {
viewModel.onRootShellWifiToggle()
},
),
),
)
if (uiState.settings.isTunnelOnWifiEnabled) {
addAll(
listOf(
SelectionItem(
Icons.Outlined.Filter1,
title = {
Text(
stringResource(R.string.use_wildcards),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
LearnMoreLinkLabel({ context.openWebUrl(it) }, stringResource(id = R.string.docs_wildcards))
},
trailing = {
ScaledSwitch(
checked = uiState.settings.isWildcardsEnabled,
onClick = {
viewModel.onToggleWildcards()
},
)
},
onClick = {
viewModel.onToggleWildcards()
},
),
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(4f, false)
.fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp.scaledWidth())
.padding(vertical = 6.dp.scaledHeight()),
) {
Text(
stringResource(R.string.trusted_wifi_names),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
uiState.settings.trustedNetworkSSIDs,
onDelete = viewModel::onDeleteTrustedSSID,
currentText = currentText,
onSave = viewModel::onSaveTrustedSSID,
onValueChange = { currentText = it },
supporting = {
if (uiState.settings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
),
),
)
}
},
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.onToggleTunnelOnMobileData() },
)
},
onClick = {
viewModel.onToggleTunnelOnMobileData()
},
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnEthernetEnabled,
onClick = { viewModel.onToggleTunnelOnEthernet() },
)
},
onClick = {
viewModel.onToggleTunnelOnEthernet()
},
),
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = uiState.settings.isPingEnabled,
onClick = { viewModel.onToggleRestartOnPing() },
)
},
onClick = {
viewModel.onToggleRestartOnPing()
},
),
),
)
}
}
}
@@ -1,131 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.AppShell
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Provider
@HiltViewModel
class AutoTunnelViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
@AppShell private val rootShell: Provider<RootShell>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val settings = appDataRepository.settings.getSettingsFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())
fun onToggleTunnelOnWifi() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnMobileDataEnabled = !isTunnelOnMobileDataEnabled,
),
)
}
}
fun onToggleWildcards() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isWildcardsEnabled = !isWildcardsEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
trustedNetworkSSIDs = (trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
fun onRootShellWifiToggle() = viewModelScope.launch {
requestRoot().onSuccess {
with(settings.value) {
appDataRepository.settings.save(
copy(isWifiNameByShellEnabled = !isWifiNameByShellEnabled),
)
}
}
}
private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) {
kotlin.runCatching {
rootShell.get().start()
SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
}
}
}
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled,
),
)
}
}
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
if (ssid.isEmpty()) return@launch
val trimmed = ssid.trim()
with(settings.value) {
if (!trustedNetworkSSIDs.contains(trimmed)) {
this.trustedNetworkSSIDs.add(ssid)
appDataRepository.settings.save(this)
} else {
SnackbarController.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
fun onToggleRestartOnPing() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
}
@@ -1,104 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TrustedNetworkTextBox(
trustedNetworks: List<String>,
onDelete: (ssid: String) -> Unit,
currentText: String,
onSave: (ssid: String) -> Unit,
onValueChange: (network: String) -> Unit,
supporting: @Composable () -> Unit,
) {
val context = LocalContext.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp.scaledHeight())) {
FlowRow(
modifier =
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally),
) {
trustedNetworks.forEach { ssid ->
ClickableIconButton(
onClick = {
if (context.isRunningOnTv()) {
onDelete(ssid)
}
},
onIconClick = {
onDelete(ssid)
},
text = ssid,
icon = Icons.Filled.Close,
)
}
}
CustomTextField(
textStyle = MaterialTheme.typography.bodySmall,
value = currentText,
onValueChange = onValueChange,
label = { Text(stringResource(R.string.add_wifi_name)) },
containerColor = MaterialTheme.colorScheme.surface,
supportingText = supporting,
modifier =
Modifier
.padding(
top = 5.dp,
bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
singleLine = true,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSave(currentText) }),
trailing = {
if (currentText != "") {
IconButton(onClick = {
onSave(currentText)
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = stringResource(
R.string
.trusted_ssid_value_description,
),
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
}
@@ -1,16 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun WildcardsLabel() {
Text(
stringResource(R.string.wildcards_active),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline, fontStyle = FontStyle.Italic),
)
}
@@ -0,0 +1,88 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun BackgroundLocationDisclosure(onDismiss: () -> Unit, onAttest: () -> Unit, scrollState: ScrollState, focusRequester: FocusRequester) {
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState),
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier =
Modifier
.padding(30.dp)
.size(128.dp),
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp,
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp,
)
Row(
modifier =
if (context.isRunningOnTv()) {
Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
onAttest()
},
) {
Text(stringResource(id = R.string.turn_on))
}
}
}
}
@@ -1,22 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@Composable
fun ForwardButton(modifier: Modifier = Modifier.focusable(), onClick: () -> Unit) {
IconButton(
modifier = modifier,
onClick = onClick,
) {
val icon = Icons.AutoMirrored.Outlined.ArrowForward
Icon(icon, icon.name, Modifier.size(iconSize))
}
}
@@ -12,18 +12,18 @@ import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun LearnMoreLinkLabel(onClick: (url: String) -> Unit, url: String) { fun WildcardSupportingLabel(onClick: (url: String) -> Unit) {
// TODO update link when docs are fully updated // TODO update link when docs are fully updated
val gettingStarted = val gettingStarted =
buildAnnotatedString { buildAnnotatedString {
pushStringAnnotation( pushStringAnnotation(
tag = "details", tag = "details",
annotation = url, annotation = stringResource(id = R.string.docs_wildcards),
) )
withStyle( withStyle(
style = SpanStyle(color = MaterialTheme.colorScheme.primary), style = SpanStyle(color = MaterialTheme.colorScheme.primary),
) { ) {
append(stringResource(id = R.string.learn_more)) append(stringResource(id = R.string.wildcard_supported))
} }
pop() pop()
} }
@@ -1,104 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.theme.topPadding
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState) {
val context = LocalContext.current
val navController = LocalNavController.current
LaunchedEffect(Unit, appUiState) {
if (appUiState.generalState.isLocationDisclosureShown) navController.goFromRoot(Route.AutoTunnel)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier.fillMaxSize().padding(top = topPadding).padding(horizontal = 24.dp.scaledWidth()),
) {
val icon = Icons.Rounded.PermScanWifi
Icon(
icon,
contentDescription = icon.name,
modifier = Modifier
.padding(30.dp.scaledHeight())
.size(128.dp.scaledHeight()),
)
Text(
stringResource(R.string.prominent_background_location_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
Text(
stringResource(R.string.prominent_background_location_message),
style = MaterialTheme.typography.bodyLarge,
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.LocationOn,
title = {
Text(
stringResource(R.string.launch_app_settings),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
context.launchAppSettings().also {
appViewModel.setLocationDisclosureShown()
}
},
trailing = {
ForwardButton {
context.launchAppSettings().also {
appViewModel.setLocationDisclosureShown()
}
}
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
title = { Text(stringResource(R.string.skip), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { appViewModel.setLocationDisclosureShown() },
trailing = {
ForwardButton { appViewModel.setLocationDisclosureShown() }
},
),
),
)
}
}
@@ -1,155 +1,310 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.filled.LineStyle import androidx.compose.material.icons.rounded.Book
import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.rounded.FormatListNumbered
import androidx.compose.material.icons.filled.Policy import androidx.compose.material.icons.rounded.Mail
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.common.label.VersionLabel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.ui.theme.topPadding
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun SupportScreen() { fun SupportScreen(focusRequester: FocusRequester, appUiState: AppUiState) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val fillMaxWidth = .85f
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topPadding) .verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp.scaledWidth()), .focusable(),
) { ) {
GroupLabel(stringResource(R.string.thank_you)) Surface(
SurfaceSelectionGroupButton( tonalElevation = 2.dp,
listOf( shadowElevation = 2.dp,
SelectionItem( shape = RoundedCornerShape(12.dp),
Icons.Filled.Book, color = MaterialTheme.colorScheme.surface,
title = { modifier =
Text( (
stringResource(R.string.docs_description), if (context.isRunningOnTv()) {
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), Modifier
) .height(IntrinsicSize.Min)
}, .fillMaxWidth(fillMaxWidth)
trailing = { .padding(top = 10.dp)
ForwardButton { context.openWebUrl(context.getString(R.string.docs_url)) } } else {
}, Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)
}
)
.padding(bottom = 25.dp),
) {
Column(modifier = Modifier.padding(20.dp)) {
val forwardIcon = Icons.AutoMirrored.Rounded.ArrowForward
Text(
stringResource(R.string.thank_you),
textAlign = TextAlign.Start,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 20.dp),
fontSize = 16.sp,
)
Text(
stringResource(id = R.string.support_help_text),
textAlign = TextAlign.Start,
fontSize = 16.sp,
modifier = Modifier.padding(bottom = 20.dp),
)
TextButton(
onClick = { onClick = {
context.openWebUrl(context.getString(R.string.docs_url)) context.openWebUrl(
}, context.resources.getString(R.string.docs_url),
),
SelectionItem(
Icons.Filled.LineStyle,
title = {
Text(
stringResource(R.string.read_logs),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
) )
}, },
trailing = { modifier =
ForwardButton { Modifier
navController.navigate(Route.Logs) .padding(vertical = 5.dp)
.focusRequester(focusRequester),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = Icons.Rounded.Book
Icon(icon, icon.name)
Text(
stringResource(id = R.string.docs_description),
textAlign = TextAlign.Justify,
modifier =
Modifier
.padding(start = 10.dp)
.weight(
weight = 1.0f,
fill = false,
),
softWrap = true,
)
} }
}, Icon(
forwardIcon,
forwardIcon.name,
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { onClick = {
navController.navigate(Route.Logs) context.openWebUrl(
}, context.resources.getString(R.string.telegram_url),
),
SelectionItem(
Icons.Filled.Policy,
title = {
Text(
stringResource(R.string.privacy_policy),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
) )
}, },
trailing = { modifier = Modifier.padding(vertical = 5.dp),
ForwardButton { context.openWebUrl(context.getString(R.string.privacy_policy_url)) } ) {
}, Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = ImageVector.vectorResource(R.drawable.telegram)
Icon(
icon,
icon.name,
Modifier.size(25.dp),
)
Text(
stringResource(id = R.string.chat_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
forwardIcon,
forwardIcon.name,
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { onClick = {
context.openWebUrl(context.getString(R.string.privacy_policy_url)) context.openWebUrl(
context.resources.getString(R.string.github_url),
)
}, },
), modifier = Modifier.padding(vertical = 5.dp),
) {
), Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = ImageVector.vectorResource(R.drawable.github)
Icon(
imageVector = icon,
icon.name,
Modifier.size(25.dp),
)
Text(
stringResource(id = R.string.open_issue),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
forwardIcon,
forwardIcon.name,
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { context.launchSupportEmail() },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = Icons.Rounded.Mail
Icon(icon, icon.name)
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
forwardIcon,
forwardIcon.name,
)
}
}
if (!context.isRunningOnTv()) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { navController.navigate(Route.Logs) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = Icons.Rounded.FormatListNumbered
Icon(icon, icon.name)
Text(
stringResource(id = R.string.read_logs),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
stringResource(id = R.string.go),
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
Text(
stringResource(id = R.string.privacy_policy),
style = TextStyle(textDecoration = TextDecoration.Underline),
fontSize = 16.sp,
modifier =
Modifier.clickable {
context.openWebUrl(
context.resources.getString(R.string.privacy_policy_url),
)
},
) )
SurfaceSelectionGroupButton( Row(
listOf( horizontalArrangement = Arrangement.spacedBy(25.dp),
SelectionItem( verticalAlignment = Alignment.CenterVertically,
ImageVector.vectorResource(R.drawable.telegram), modifier = Modifier.padding(25.dp),
title = { ) {
Text( val version =
stringResource(R.string.chat_description), buildAnnotatedString {
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), append(stringResource(id = R.string.version))
) append(": ")
}, append(BuildConfig.VERSION_NAME)
trailing = { }
ForwardButton { val mode =
context.openWebUrl(context.getString(R.string.telegram_url)) buildAnnotatedString {
} append(stringResource(R.string.mode))
}, append(": ")
onClick = { when (appUiState.settings.isKernelEnabled) {
context.openWebUrl(context.getString(R.string.telegram_url)) true -> append(stringResource(id = R.string.kernel))
}, false -> append(stringResource(id = R.string.userspace))
), }
SelectionItem( }
ImageVector.vectorResource(R.drawable.github), Text(version.text, modifier = Modifier.focusable())
title = { Text(stringResource(R.string.open_issue), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) }, Text(mode.text)
trailing = { }
ForwardButton {
context.openWebUrl(context.getString(R.string.github_url))
}
},
onClick = {
context.openWebUrl(context.getString(R.string.github_url))
},
),
SelectionItem(
Icons.Filled.Mail,
title = {
Text(
stringResource(R.string.email_description),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton {
context.launchSupportEmail()
}
},
onClick = {
context.launchSupportEmail()
},
),
),
)
VersionLabel()
} }
} }
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -31,17 +32,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.logcatter.model.LogMessage import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) { fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
val logs = viewModel.logs val logs = viewModel.logs
@@ -84,9 +83,6 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
} }
Scaffold( Scaffold(
topBar = {
TopNavBar(stringResource(R.string.logs))
},
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -111,7 +107,7 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 24.dp).padding(it), .padding(horizontal = 24.dp),
) { ) {
itemsIndexed( itemsIndexed(
logs, logs,
@@ -11,7 +11,6 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -20,6 +19,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.io.File
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -29,7 +29,6 @@ class LogsViewModel
@Inject @Inject
constructor( constructor(
private val localLogCollector: LogReader, private val localLogCollector: LogReader,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher, @MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -52,7 +51,12 @@ constructor(
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) { fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
runCatching { runCatching {
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip") val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path + "/" + Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
if (file.exists()) file.delete()
file.createNewFile()
localLogCollector.zipLogFiles(file.absolutePath) localLogCollector.zipLogFiles(file.absolutePath)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file) val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
context.launchShareFile(uri) context.launchShareFile(uri)
@@ -3,14 +3,14 @@ package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val OffWhite = Color(0xFFE5E1E5) val OffWhite = Color(0xFFE5E1E5)
val LightGrey = Color(0xFFCAC4D0) val LightGrey = Color(0xFF8D9D9F)
val Aqua = Color(0xFF76BEBD) val Aqua = Color(0xFF76BEBD)
val SilverTree = Color(0xFF6DB58B) val SilverTree = Color(0xFF6DB58B)
val Plantation = Color(0xFF264A49) val Plantation = Color(0xFF264A49)
val Shark = Color(0xFF21272A) val Shark = Color(0xFF21272A)
val BalticSea = Color(0xFF1C1B1F) val BalticSea = Color(0xFF1C1B1F)
val Brick = Color(0xFFCE4257) val Brick = Color(0xFFCE4257)
val Straw = Color(0xFFD4C483) val Corn = Color(0xFFFBEC5D)
sealed class ThemeColors( sealed class ThemeColors(
val background: Color, val background: Color,
@@ -19,7 +19,7 @@ sealed class ThemeColors(
val secondary: Color, val secondary: Color,
val onSurface: Color, val onSurface: Color,
) { ) {
// TODO fix light theme colors
data object Light : ThemeColors( data object Light : ThemeColors(
background = LightGrey, background = LightGrey,
surface = OffWhite, surface = OffWhite,
@@ -1,7 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
val iconSize = 24.dp.scaledHeight()
val topPadding = 80.dp.scaledHeight()
@@ -36,39 +36,24 @@ private val LightColorScheme =
onSecondaryContainer = ThemeColors.Light.primary, onSecondaryContainer = ThemeColors.Light.primary,
) )
enum class Theme {
AUTOMATIC,
LIGHT,
DARK,
DYNAMIC,
}
@Composable @Composable
fun WireguardAutoTunnelTheme(theme: Theme = Theme.AUTOMATIC, content: @Composable () -> Unit) { fun WireguardAutoTunnelTheme(
// force dark theme
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
var isDark = isSystemInDarkTheme() val colorScheme = when {
val autoTheme = if (isDark) DarkColorScheme else LightColorScheme (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) -> {
val colorScheme = when (theme) { if (useDarkTheme) {
Theme.AUTOMATIC -> autoTheme dynamicDarkColorScheme(context)
Theme.DARK -> {
isDark = true
DarkColorScheme
}
Theme.LIGHT -> {
isDark = false
LightColorScheme
}
Theme.DYNAMIC -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (isDark) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
} else { } else {
autoTheme dynamicLightColorScheme(context)
} }
} }
useDarkTheme -> DarkColorScheme
// TODO force dark theme for now until light theme designed
else -> DarkColorScheme
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -77,7 +62,8 @@ fun WireguardAutoTunnelTheme(theme: Theme = Theme.AUTOMATIC, content: @Composabl
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb() window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !isDark WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
!useDarkTheme
} }
} }
@@ -5,9 +5,9 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaled
// Set of Material typography styles to start with // Set of Material typography styles to start with
@@ -17,47 +17,43 @@ val inter = FontFamily(
val Typography = val Typography =
Typography( Typography(
bodyLarge = TextStyle( bodyLarge =
TextStyle(
fontFamily = inter, fontFamily = inter,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp.scaled(), fontSize = 16.sp,
lineHeight = 24.sp.scaled(), lineHeight = 24.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
bodySmall = TextStyle( bodySmall = TextStyle(
fontFamily = inter, fontFamily = inter,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp.scaled(), fontSize = 13.sp,
lineHeight = 20.sp.scaled(), lineHeight = 20.sp,
letterSpacing = 1.sp, letterSpacing = 1.sp,
color = LightGrey, color = LightGrey,
), ),
bodyMedium = TextStyle(
fontFamily = inter,
fontSize = 14.sp.scaled(),
lineHeight = 20.sp.scaled(),
fontWeight = FontWeight(400),
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle( labelLarge = TextStyle(
fontFamily = inter, fontFamily = inter,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 15.sp.scaled(), fontSize = 15.sp,
lineHeight = 18.sp.scaled(), lineHeight = 18.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
labelMedium = TextStyle( labelMedium = TextStyle(
fontFamily = inter, fontFamily = inter,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp.scaled(), fontSize = 12.sp,
lineHeight = 16.sp.scaled(), lineHeight = 16.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
titleMedium = TextStyle( titleMedium = TextStyle(
fontFamily = inter, fontFamily = inter,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 16.sp.scaled(), fontSize = 17.sp,
lineHeight = 21.sp.scaled(), lineHeight = 21.sp,
letterSpacing = 0.sp, letterSpacing = 0.sp,
), ),
) )
val iconSize = 15.dp
@@ -21,6 +21,7 @@ object Constants {
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L const val SUBSCRIPTION_TIMEOUT = 5_000L
const val FOCUS_REQUEST_DELAY = 500L
const val TRANSITION_ANIMATION_TIME = 200 const val TRANSITION_ANIMATION_TIME = 200
@@ -1,13 +1,19 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.OutputStream
import java.time.Instant
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@@ -16,60 +22,73 @@ class FileUtils(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
) { ) {
suspend fun createWgFiles(tunnels: TunnelConfigs): List<File> { fun createWgFiles(tunnels: TunnelConfigs): List<File> {
return withContext(ioDispatcher) { return tunnels.map { config ->
tunnels.map { config -> val file = File(context.cacheDir, "${config.name}-wg.conf")
val file = File(context.cacheDir, "${config.name}-wg.conf") file.outputStream().use {
file.outputStream().use { it.write(config.wgQuick.toByteArray())
it.write(config.wgQuick.toByteArray())
}
file
} }
}
}
suspend fun createAmFiles(tunnels: TunnelConfigs): List<File> {
return withContext(ioDispatcher) {
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
}
suspend fun zipAll(zipFile: File, files: List<File>) {
withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file ->
val zipFileName = (
file.parentFile?.let { parent ->
file.absolutePath.removePrefix(parent.absolutePath)
} ?: file.absolutePath
).removePrefix("/")
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use {
it.copyTo(zos)
}
}
}
}
}
}
suspend fun createNewShareFile(name: String): File {
return withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path}/$name")
if (file.exists()) file.delete()
file.createNewFile()
file file
} }
} }
fun createAmFiles(tunnels: TunnelConfigs): List<File> {
return tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.name}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
suspend fun saveFilesToZip(files: List<File>): Result<Unit> {
return withContext(ioDispatcher) {
try {
val zipOutputStream =
createDownloadsFileOutputStream(
"wg-export_${Instant.now().epochSecond}.zip",
Constants.ZIP_FILE_MIME_TYPE,
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
return@withContext Result.success(Unit)
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(ConfigExportException)
}
}
}
// TODO issue with android 9
private fun createDownloadsFileOutputStream(fileName: String, mimeType: String = Constants.ALL_FILE_TYPES): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName,
)
return target.outputStream()
}
return null
}
} }
@@ -1,100 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
import android.os.LocaleList
import androidx.core.os.ConfigurationCompat
import com.zaneschepke.wireguardautotunnel.BuildConfig
import java.util.Locale
object LocaleUtil {
private const val DEFAULT_LANG = "en"
val supportedLocales: Array<String> = BuildConfig.LANGUAGES
const val OPTION_PHONE_LANGUAGE = "sys_def"
/**
* returns the locale to use depending on the preference value
* when preference value = "sys_def" returns the locale of current system
* else it returns the locale code e.g. "en", "bn" etc.
*/
fun getLocaleFromPrefCode(prefCode: String): Locale {
val localeCode = if (prefCode != OPTION_PHONE_LANGUAGE) {
prefCode
} else {
val systemLang = ConfigurationCompat.getLocales(Resources.getSystem().configuration).get(0)?.language ?: DEFAULT_LANG
if (systemLang in supportedLocales) {
systemLang
} else {
DEFAULT_LANG
}
}
return Locale.forLanguageTag(localeCode)
}
fun getLocalizedConfiguration(prefLocaleCode: String): Configuration {
val locale = getLocaleFromPrefCode(prefLocaleCode)
return getLocalizedConfiguration(locale)
}
private fun getLocalizedConfiguration(locale: Locale): Configuration {
val config = Configuration()
return config.apply {
config.setLayoutDirection(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
config.setLocale(locale)
val localeList = LocaleList(locale)
LocaleList.setDefault(localeList)
config.setLocales(localeList)
} else {
config.setLocale(locale)
}
}
}
fun getLocalizedContext(baseContext: Context, prefLocaleCode: String?): Context {
if (prefLocaleCode == null) return baseContext
val currentLocale = getLocaleFromPrefCode(prefLocaleCode)
val baseLocale = getLocaleFromConfiguration(baseContext.resources.configuration)
Locale.setDefault(currentLocale)
return if (!baseLocale.toString().equals(currentLocale.toString(), ignoreCase = true)) {
val config = getLocalizedConfiguration(currentLocale)
baseContext.createConfigurationContext(config)
baseContext
} else {
baseContext
}
}
fun applyLocalizedContext(baseContext: Context, prefLocaleCode: String) {
val currentLocale = getLocaleFromPrefCode(prefLocaleCode)
val baseLocale = getLocaleFromConfiguration(baseContext.resources.configuration)
Locale.setDefault(currentLocale)
if (!baseLocale.toString().equals(currentLocale.toString(), ignoreCase = true)) {
val config = getLocalizedConfiguration(currentLocale)
baseContext.resources.updateConfiguration(config, baseContext.resources.displayMetrics)
}
}
@Suppress("DEPRECATION")
private fun getLocaleFromConfiguration(configuration: Configuration): Locale {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
configuration.locales.get(0)
} else {
configuration.locale
}
}
fun getLocalizedResources(resources: Resources, prefLocaleCode: String): Resources {
val locale = getLocaleFromPrefCode(prefLocaleCode)
val config = resources.configuration
@Suppress("DEPRECATION")
config.locale = locale
config.setLayoutDirection(locale)
@Suppress("DEPRECATION")
resources.updateConfiguration(config, resources.displayMetrics)
return resources
}
}
@@ -1,26 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
open class SingletonHolder<out T : Any, in A>(creator: (A) -> T) {
private var creator: ((A) -> T)? = creator
@Volatile private var instance: T? = null
fun getInstance(arg: A): T {
val i = instance
if (i != null) {
return i
}
return synchronized(this) {
val i2 = instance
if (i2 != null) {
i2
} else {
val created = creator!!(arg)
instance = created
creator = null
created
}
}
}
}
@@ -2,27 +2,18 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.core.location.LocationManagerCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.BackgroundActionReceiver
import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile import com.zaneschepke.wireguardautotunnel.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
private const val BASELINE_HEIGHT = 2201
private const val BASELINE_WIDTH = 1080
private const val BASELINE_DENSITY = 2.625
fun Context.openWebUrl(url: String): Result<Unit> { fun Context.openWebUrl(url: String): Result<Unit> {
return kotlin.runCatching { return kotlin.runCatching {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
@@ -35,50 +26,6 @@ fun Context.openWebUrl(url: String): Result<Unit> {
} }
} }
fun Context.isBatteryOptimizationsDisabled(): Boolean {
val pm = getSystemService(POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(packageName)
}
val Context.actionBarSize
get() = theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize))
.let { attrs -> attrs.getDimension(0, 0F).toInt().also { attrs.recycle() } }
fun Context.resizeHeight(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height = displayMetrics.heightPixels - this.actionBarSize
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeHeightPercentage
}
fun Context.resizeHeight(textUnit: TextUnit): TextUnit {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val height = displayMetrics.heightPixels - actionBarSize
val resizeHeightPercentage =
(height.toFloat() / BASELINE_HEIGHT) * (BASELINE_DENSITY.toFloat() / density)
return textUnit * resizeHeightPercentage * 1.1
}
fun Context.resizeWidth(dp: Dp): Dp {
val displayMetrics = resources.displayMetrics
val density = displayMetrics.density
val width = displayMetrics.widthPixels
val resizeWidthPercentage =
(width.toFloat() / BASELINE_WIDTH) * (BASELINE_DENSITY.toFloat() / density)
return dp * resizeWidthPercentage
}
fun Context.launchNotificationSettings() {
if (isRunningOnTv()) return launchAppSettings()
val settingsIntent: Intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
this.startActivity(settingsIntent)
}
fun Context.launchShareFile(file: Uri) { fun Context.launchShareFile(file: Uri) {
val shareIntent = Intent().apply { val shareIntent = Intent().apply {
setAction(Intent.ACTION_SEND) setAction(Intent.ACTION_SEND)
@@ -89,14 +36,6 @@ fun Context.launchShareFile(file: Uri) {
this.startActivity(Intent.createChooser(shareIntent, "")) this.startActivity(Intent.createChooser(shareIntent, ""))
} }
fun Context.isLocationServicesEnabled(): Boolean {
val locationManager =
getSystemService(
Context.LOCATION_SERVICE,
) as LocationManager
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun Context.showToast(resId: Int) { fun Context.showToast(resId: Int) {
Toast.makeText( Toast.makeText(
this, this,
@@ -165,23 +104,23 @@ fun Context.launchAppSettings() {
} }
} }
// fun Context.startTunnelBackground(tunnelId: Int) { fun Context.startTunnelBackground(tunnelId: Int) {
// sendBroadcast( sendBroadcast(
// Intent(this, BackgroundActionReceiver::class.java).apply { Intent(this, BackgroundActionReceiver::class.java).apply {
// action = BackgroundActionReceiver.ACTION_CONNECT action = BackgroundActionReceiver.ACTION_CONNECT
// putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId) putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId)
// }, },
// ) )
// } }
//
// fun Context.stopTunnelBackground(tunnelId: Int) { fun Context.stopTunnelBackground(tunnelId: Int) {
// sendBroadcast( sendBroadcast(
// Intent(this, BackgroundActionReceiver::class.java).apply { Intent(this, BackgroundActionReceiver::class.java).apply {
// action = BackgroundActionReceiver.ACTION_DISCONNECT action = BackgroundActionReceiver.ACTION_DISCONNECT
// putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId) putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId)
// }, },
// ) )
// } }
fun Context.requestTunnelTileServiceStateUpdate() { fun Context.requestTunnelTileServiceStateUpdate() {
TileService.requestListeningState( TileService.requestListeningState(
@@ -28,9 +28,9 @@ fun String.extractNameAndNumber(): Pair<String, Int>? {
} }
fun List<String>.isMatchingToWildcardList(value: String): Boolean { fun List<String>.isMatchingToWildcardList(value: String): Boolean {
val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").transformWildcardsToRegex() } val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").toRegexWithWildcards() }
Timber.d("Excluded values: $excludeValues") Timber.d("Excluded values: $excludeValues")
val includedValues = this.filter { !it.startsWith("!") }.map { it.transformWildcardsToRegex() } val includedValues = this.filter { !it.startsWith("!") }.map { it.toRegexWithWildcards() }
Timber.d("Included values: $includedValues") Timber.d("Included values: $includedValues")
val matches = includedValues.filter { it.matches(value) } val matches = includedValues.filter { it.matches(value) }
val excludedMatches = excludeValues.filter { it.matches(value) } val excludedMatches = excludeValues.filter { it.matches(value) }
@@ -39,32 +39,6 @@ fun List<String>.isMatchingToWildcardList(value: String): Boolean {
return matches.isNotEmpty() && excludedMatches.isEmpty() return matches.isNotEmpty() && excludedMatches.isEmpty()
} }
fun String.transformWildcardsToRegex(): Regex { fun String.toRegexWithWildcards(): Regex {
return this.replaceUnescapedChar("*", ".*").replaceUnescapedChar("?", ".").toRegex() return this.replace("*", ".*").replace("?", ".").toRegex()
}
fun String.replaceUnescapedChar(charToReplace: String, replacement: String): String {
val escapedChar = Regex.escape(charToReplace)
val regex = "(?<!\\\\)(?<!(?<!\\\\)\\\\)($escapedChar)".toRegex()
return regex.replace(this) { matchResult ->
if (matchResult.range.first == 0 ||
this[matchResult.range.first - 1] != '\\' ||
(matchResult.range.first > 1 && this[matchResult.range.first - 2] == '\\')
) {
replacement.toString()
} else {
matchResult.value
}
}
}
fun String.isCharacterEscaped(index: Int): Boolean {
if (index <= 0) return false
var backslashCount = 0
var currentIndex = index - 1
while (currentIndex >= 0 && this[currentIndex] == '\\') {
backslashCount++
currentIndex--
}
return backslashCount % 2 != 0
} }
@@ -5,8 +5,8 @@ import com.wireguard.android.util.RootShell
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.theme.Corn
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import org.amnezia.awg.config.Config import org.amnezia.awg.config.Config
@@ -58,7 +58,7 @@ fun TunnelStatistics?.asColor(): Color {
?.let { statuses -> ?.let { statuses ->
when { when {
statuses.all { it == HandshakeStatus.HEALTHY } -> SilverTree statuses.all { it == HandshakeStatus.HEALTHY } -> SilverTree
statuses.any { it == HandshakeStatus.STALE } -> Straw statuses.any { it == HandshakeStatus.STALE } -> Corn
statuses.all { it == HandshakeStatus.NOT_STARTED } -> Color.Gray statuses.all { it == HandshakeStatus.NOT_STARTED } -> Color.Gray
else -> Color.Gray else -> Color.Gray
} }
@@ -1,43 +1,10 @@
package com.zaneschepke.wireguardautotunnel.util.extensions package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
fun NavController.navigateAndForget(route: Route) { fun NavController.navigateAndForget(route: Route) {
navigate(route) { navigate(route) {
popUpTo(0) popUpTo(0)
} }
} }
fun NavController.goFromRoot(route: Route) {
if (currentBackStackEntry?.isCurrentRoute(route::class) == true) return
this.navigate(route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
restoreState = true
}
}
fun Dp.scaledHeight(): Dp {
return WireGuardAutoTunnel.instance.resizeHeight(this)
}
fun Dp.scaledWidth(): Dp {
return WireGuardAutoTunnel.instance.resizeWidth(this)
}
fun TextUnit.scaled(): TextUnit {
return WireGuardAutoTunnel.instance.resizeHeight(this)
}
+2 -29
View File
@@ -23,7 +23,7 @@
<string name="enable_auto_tunnel">Start auto-tunneling</string> <string name="enable_auto_tunnel">Start auto-tunneling</string>
<string name="disable_auto_tunnel">Stop auto-tunneling</string> <string name="disable_auto_tunnel">Stop auto-tunneling</string>
<string name="tunnel_mobile_data">Tunnel on mobile data</string> <string name="tunnel_mobile_data">Tunnel on mobile data</string>
<string name="privacy_policy">View privacy policy</string> <string name="privacy_policy">View Privacy Policy</string>
<string name="okay">Okay</string> <string name="okay">Okay</string>
<string name="tunnel_on_ethernet">Tunnel on ethernet</string> <string name="tunnel_on_ethernet">Tunnel on ethernet</string>
<string name="prominent_background_location_message">This feature requires background location permission to enable Wi-Fi SSID monitoring even while the application is closed. For more details, please see the Privacy Policy linked on the Support screen.</string> <string name="prominent_background_location_message">This feature requires background location permission to enable Wi-Fi SSID monitoring even while the application is closed. For more details, please see the Privacy Policy linked on the Support screen.</string>
@@ -193,38 +193,11 @@
<string name="set_custom_ping_internal">Ping interval (sec)</string> <string name="set_custom_ping_internal">Ping interval (sec)</string>
<string name="optional_default">"optional, default: "</string> <string name="optional_default">"optional, default: "</string>
<string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string> <string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string>
<string name="wildcard_supported">Learn about supported wildcards.</string>
<string name="details">details</string> <string name="details">details</string>
<string name="show_amnezia_properties">Show Amnezia properties</string> <string name="show_amnezia_properties">Show Amnezia properties</string>
<string name="never">never</string> <string name="never">never</string>
<string name="sec">sec</string> <string name="sec">sec</string>
<string name="handshake">handshake</string> <string name="handshake">handshake</string>
<string name="logs">Logs</string> <string name="logs">Logs</string>
<string name="tunnel_notifications">Tunnel notifications</string>
<string name="kill_switch">Kill switch</string>
<string name="appearance">Appearance</string>
<string name="notifications">Notifications</string>
<string name="automatic">Automatic</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="dynamic">Dynamic</string>
<string name="language">Language</string>
<string name="display_theme">Display theme</string>
<string name="selected">Selected</string>
<string name="trusted_wifi_names">Trusted wifi names</string>
<string name="add_wifi_name">Add wifi name</string>
<string name="on_demand_rules">On demand tunnel rules</string>
<string name="primary_tunnel">Primary tunnel</string>
<string name="mobile_tunnel">Mobile data tunnel</string>
<string name="skip">Skip</string>
<string name="launch_app_settings">Launch app settings</string>
<string name="use_wildcards">Use name wildcards</string>
<string name="learn_more">Learn more</string>
<string name="wildcards_active">Wildcards active</string>
<string name="wifi_name_via_shell">Wifi name via shell</string>
<string name="use_root_shell_for_wifi">Use root shell to get wifi name</string>
<string name="kernel_not_supported">Kernel not supported</string>
<string name="start_auto">Start auto-tunnel</string>
<string name="stop_auto">Stop auto-tunnel</string>
<string name="tunnel_running">Tunnel running</string>
<string name="monitoring_state_changes">Monitoring state changes</string>
</resources> </resources>
+4 -4
View File
@@ -37,8 +37,8 @@
android:enabled="true" android:enabled="true"
android:icon="@drawable/auto_play" android:icon="@drawable/auto_play"
android:shortcutId="autoOn1" android:shortcutId="autoOn1"
android:shortcutLongLabel="@string/start_auto" android:shortcutLongLabel="@string/auto_on"
android:shortcutShortLabel="@string/start_auto"> android:shortcutShortLabel="@string/auto_tun_on">
<intent <intent
android:action="START" android:action="START"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity" android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"
@@ -53,8 +53,8 @@
android:enabled="true" android:enabled="true"
android:icon="@drawable/auto_pause" android:icon="@drawable/auto_pause"
android:shortcutId="autoOff1" android:shortcutId="autoOff1"
android:shortcutLongLabel="@string/stop_auto" android:shortcutLongLabel="@string/auto_off"
android:shortcutShortLabel="@string/stop_auto"> android:shortcutShortLabel="@string/auto_tun_off">
<intent <intent
android:action="STOP" android:action="STOP"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity" android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity"
+3 -3
View File
@@ -1,8 +1,8 @@
object Constants { object Constants {
const val VERSION_NAME = "3.6.0" const val VERSION_NAME = "3.5.4"
const val JVM_TARGET = "17" const val JVM_TARGET = "17"
const val VERSION_CODE = 36000 const val VERSION_CODE = 35400
const val TARGET_SDK = 35 const val TARGET_SDK = 34
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_ID = "com.zaneschepke.wireguardautotunnel"
const val APP_NAME = "wgtunnel" const val APP_NAME = "wgtunnel"
-12
View File
@@ -72,18 +72,6 @@ fun Project.getSigningProperty(property: String): String {
) )
} }
fun Project.languageList(): List<String> {
return fileTree("../app/src/main/res") { include("**/strings.xml") }
.asSequence()
.map { stringFile -> stringFile.parentFile.name }
.map { valuesFolderName -> valuesFolderName.replace("values-", "") }
.filter { valuesFolderName -> valuesFolderName != "values" }
.map { languageCode -> languageCode.replace("-r", "_") }
.distinct()
.sorted()
.toList() + "en"
}
@@ -0,0 +1,5 @@
What's new:
- Fix Android 12 crashing issue
- Fix copy tunnel bug
- Auto toggle Amnezia props
- Hide preshared key without auth
@@ -1,6 +0,0 @@
What's new:
- UI update
- AndroidTV navigation improvements
- Battery drain bug fix
- Wildcards fix with optional setting
- Other improvements
+6 -6
View File
@@ -6,7 +6,7 @@ androidx-junit = "1.2.1"
appcompat = "1.7.0" appcompat = "1.7.0"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coreGoogleShortcuts = "1.1.0" coreGoogleShortcuts = "1.1.0"
coreKtx = "1.15.0" coreKtx = "1.13.1"
datastorePreferences = "1.1.1" datastorePreferences = "1.1.1"
desugar_jdk_libs = "2.1.2" desugar_jdk_libs = "2.1.2"
espressoCore = "3.6.1" espressoCore = "3.6.1"
@@ -14,18 +14,18 @@ hiltAndroid = "2.52"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.7.3" kotlinx-serialization-json = "1.7.3"
lifecycle-runtime-compose = "2.8.7" lifecycle-runtime-compose = "2.8.6"
material3 = "1.3.1" material3 = "1.3.0"
navigationCompose = "2.8.3" navigationCompose = "2.8.3"
pinLockCompose = "1.0.4" pinLockCompose = "1.0.4"
roomVersion = "2.6.1" roomVersion = "2.6.1"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.2.1" tunnel = "1.2.1"
androidGradlePlugin = "8.7.2" androidGradlePlugin = "8.7.1"
kotlin = "2.0.21" kotlin = "2.0.21"
ksp = "2.0.21-1.0.25" ksp = "2.0.21-1.0.25"
composeBom = "2024.10.01" composeBom = "2024.10.00"
compose = "1.7.5" compose = "1.7.4"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1" coreSplashscreen = "1.0.1"
gradlePlugins-grgit = "5.3.0" gradlePlugins-grgit = "5.3.0"
+1 -1
View File
@@ -1 +1 @@
4 10