From aeb4a133895b9f9df5ea7676c38e6df33d8d9b5b Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Sun, 24 Dec 2023 18:09:23 -0500 Subject: [PATCH] feat: androidtv navigation, auto-tunneling pause Improved AndroidTV navigation to be less clunky and more streamlined Added auto-tunneling pause feature to UI to allow of quick auto tunneling pauses. App shortcuts and quick tile also override auto tunneling by engaging pause for temporary override of VPN purposes. Fixed bug where auto start on reboot was not working on older devices and AndroidTV. Fixed bug where location services is prefenting some flavors of Android from using auto-tunneling. Fixed bug where location permissions were not being detected correctly on AndroidTV versions. Fixed bug where quick tile could become out of sync. Improved notifications to show proper state of auto-tunneling and vpn. Removed excessive vibration from notifications. Improved error handling. Closes #75 Closes #73 Closes #61 Closes #53 Closes #30 --- .github/workflows/android.yml | 2 +- app/build.gradle.kts | 1 + app/fdroid-rules.pro | 2 +- app/proguard-rules.pro | 2 + .../1.json | 0 .../2.json | 0 .../3.json | 0 .../4.json | 0 .../5.json | 161 +++ .../wireguardautotunnel/MigrationTest.kt | 38 +- app/src/main/AndroidManifest.xml | 8 +- .../wireguardautotunnel/Extensions.kt | 31 - .../WireGuardAutoTunnel.kt | 30 +- .../{repository => data}/AppDatabase.kt | 13 +- .../DatabaseListConverters.kt | 2 +- .../SettingsDoa.kt => data/SettingsDao.kt} | 9 +- .../{repository => data}/TunnelConfigDao.kt | 4 +- .../datastore/DataStoreManager.kt | 8 +- .../{repository => data}/model/Settings.kt | 8 +- .../model/TunnelConfig.kt | 4 +- .../data/repository/SettingsRepository.kt | 12 + .../data/repository/SettingsRepositoryImpl.kt | 24 + .../data/repository/TunnelConfigRepository.kt | 14 + .../repository/TunnelConfigRepositoryImpl.kt | 28 + .../module/DatabaseModule.kt | 2 +- .../module/RepositoryModule.kt | 28 +- .../module/TunnelModule.kt | 6 +- .../receiver/BootReceiver.kt | 31 +- .../receiver/NotificationActionReceiver.kt | 23 +- .../service/foreground/ServiceManager.kt | 37 +- .../WireGuardConnectivityWatcherService.kt | 562 ++++++----- .../foreground/WireGuardTunnelService.kt | 133 ++- .../notification/NotificationService.kt | 4 +- .../notification/WireGuardNotification.kt | 37 +- .../service/shortcut/ShortcutsActivity.kt | 46 +- .../service/tile/TunnelControlTile.kt | 158 ++- .../service/tunnel/HandshakeStatus.kt | 3 +- .../service/tunnel/VpnService.kt | 12 +- .../service/tunnel/VpnState.kt | 10 + .../service/tunnel/WireGuardTunnel.kt | 138 +-- .../ui/ActivityViewModel.kt | 9 +- .../wireguardautotunnel/ui/MainActivity.kt | 137 +-- .../wireguardautotunnel/ui/Routes.kt | 36 - .../wireguardautotunnel/ui/Screen.kt | 33 + .../ui/common/ClickableIconButton.kt | 6 +- .../ui/common/RowListItem.kt | 4 +- .../ui/common/prompt/CustomSnackbar.kt | 2 +- .../ui/common/screen/LoadingScreen.kt | 22 + .../ui/screens/config/ConfigScreen.kt | 938 +++++++----------- .../ui/screens/config/ConfigUiState.kt | 18 + .../ui/screens/config/ConfigViewModel.kt | 677 +++++-------- .../ui/screens/main/MainScreen.kt | 866 ++++++++-------- .../ui/screens/main/MainUiState.kt | 12 + .../ui/screens/main/MainViewModel.kt | 421 ++++---- .../ui/screens/settings/SettingsScreen.kt | 860 +++++++--------- .../ui/screens/settings/SettingsUiState.kt | 13 + .../ui/screens/settings/SettingsViewModel.kt | 212 ++-- .../ui/screens/support/SupportScreen.kt | 332 +++---- .../ui/screens/support/SupportUiState.kt | 8 + .../ui/screens/support/SupportViewModel.kt | 28 +- .../{ => util}/Constants.kt | 15 +- .../wireguardautotunnel/util/Event.kt | 90 ++ .../wireguardautotunnel/util/Extensions.kt | 68 ++ .../wireguardautotunnel/util/FileUtils.kt | 1 - .../wireguardautotunnel/util/Result.kt | 16 + .../util/WgTunnelException.kt | 16 - app/src/main/res/values/strings.xml | 26 +- buildSrc/src/main/kotlin/Constants.kt | 7 +- .../android/en-US/changelogs/33000.txt | 8 + 69 files changed, 3184 insertions(+), 3328 deletions(-) rename app/schemas/{com.zaneschepke.wireguardautotunnel.repository.AppDatabase => com.zaneschepke.wireguardautotunnel.data.AppDatabase}/1.json (100%) rename app/schemas/{com.zaneschepke.wireguardautotunnel.repository.AppDatabase => com.zaneschepke.wireguardautotunnel.data.AppDatabase}/2.json (100%) rename app/schemas/{com.zaneschepke.wireguardautotunnel.repository.AppDatabase => com.zaneschepke.wireguardautotunnel.data.AppDatabase}/3.json (100%) rename app/schemas/{com.zaneschepke.wireguardautotunnel.repository.AppDatabase => com.zaneschepke.wireguardautotunnel.data.AppDatabase}/4.json (100%) create mode 100644 app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/AppDatabase.kt (64%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/DatabaseListConverters.kt (92%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository/SettingsDoa.kt => data/SettingsDao.kt} (77%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/TunnelConfigDao.kt (86%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/datastore/DataStoreManager.kt (80%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/model/Settings.kt (88%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{repository => data}/model/TunnelConfig.kt (94%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepositoryImpl.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepositoryImpl.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/screen/LoadingScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/{ => util}/Constants.kt (60%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Event.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Extensions.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Result.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/33000.txt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b3ba25f..bb3ffda 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -70,7 +70,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: # fix hardcode changelog file name - body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32500.txt + body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/33000.txt tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} draft: false diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae8e5ac..d789afd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,7 @@ android { defaultConfig { applicationId = Constants.APP_ID minSdk = Constants.MIN_SDK + compileSdk = Constants.COMPILE_SDK targetSdk = Constants.TARGET_SDK versionCode = Constants.VERSION_CODE versionName = Constants.VERSION_NAME diff --git a/app/fdroid-rules.pro b/app/fdroid-rules.pro index 86f6534..835738c 100644 --- a/app/fdroid-rules.pro +++ b/app/fdroid-rules.pro @@ -2,4 +2,4 @@ -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { ; -} \ No newline at end of file +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2c8662e..bb7a397 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,3 +22,5 @@ -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { ; } + + diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/1.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/1.json similarity index 100% rename from app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/1.json rename to app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/1.json diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/2.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/2.json similarity index 100% rename from app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/2.json rename to app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/2.json diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/3.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/3.json similarity index 100% rename from app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/3.json rename to app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/3.json diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/4.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/4.json similarity index 100% rename from app/schemas/com.zaneschepke.wireguardautotunnel.repository.AppDatabase/4.json rename to app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/4.json diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json new file mode 100644 index 0000000..25f1248 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/5.json @@ -0,0 +1,161 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "bc15003a44746e18b9c260ec49737089", + "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, `default_tunnel` TEXT, `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_battery_saver_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_auto_tunnel_paused` 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": "defaultTunnel", + "columnName": "default_tunnel", + "affinity": "TEXT", + "notNull": false + }, + { + "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": "isBatterySaverEnabled", + "columnName": "is_battery_saver_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": "isAutoTunnelPaused", + "columnName": "is_auto_tunnel_paused", + "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)", + "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 + } + ], + "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, 'bc15003a44746e18b9c260ec49737089')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/MigrationTest.kt b/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/MigrationTest.kt index cfc4772..987e432 100644 --- a/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/MigrationTest.kt +++ b/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/MigrationTest.kt @@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.zaneschepke.wireguardautotunnel.repository.AppDatabase -import java.io.IOException +import com.zaneschepke.wireguardautotunnel.data.AppDatabase import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.IOException @RunWith(AndroidJUnit4::class) class MigrationTest { @@ -21,30 +21,36 @@ class MigrationTest { @Test @Throws(IOException::class) - fun migrate2To3() { - helper.createDatabase(dbName, 3).apply { + fun migrate4To5() { + helper.createDatabase(dbName, 4).apply { // Database has schema version 1. Insert some data using SQL queries. // You can't use DAO classes because they expect the latest schema. execSQL( - "INSERT INTO Settings (is_tunnel_enabled, " + + "INSERT INTO Settings (is_tunnel_enabled," + "is_tunnel_on_mobile_data_enabled," + "trusted_network_ssids," + - "default_tunnel, " + + "default_tunnel," + "is_always_on_vpn_enabled," + "is_tunnel_on_ethernet_enabled," + "is_shortcuts_enabled," + "is_battery_saver_enabled," + - "is_tunnel_on_wifi_enabled)" + - " VALUES (" + - "false," + - "false," + + "is_tunnel_on_wifi_enabled," + + "is_kernel_enabled," + + "is_restore_on_boot_enabled," + + "is_multi_tunnel_enabled)" + + " VALUES " + + "('false'," + + "'false'," + "'[trustedSSID1,trustedSSID2]'," + "'defaultTunnel'," + - "false," + - "false," + - "false," + - "false," + - "false)" + "'false'," + + "'false'," + + "'false'," + + "'false'," + + "'false'," + + "'false'," + + "'false'," + + "'false')" ) execSQL( "INSERT INTO TunnelConfig (name, wg_quick)" + @@ -56,7 +62,7 @@ class MigrationTest { // Re-open the database with version 2 and provide // MIGRATION_1_2 as the migration process. - helper.runMigrationsAndValidate(dbName, 4, true) + helper.runMigrationsAndValidate(dbName, 5, true) // MigrationTestHelper automatically verifies the schema changes, // but you need to validate that the data was migrated properly. } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bd4e55e..5a31be9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,9 +126,13 @@ android:exported="false"> + android:exported="false"> - + + + + + diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt deleted file mode 100644 index 0236bfb..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.zaneschepke.wireguardautotunnel - -import android.content.BroadcastReceiver -import java.math.BigDecimal -import java.text.DecimalFormat -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -fun BroadcastReceiver.goAsync( - context: CoroutineContext = EmptyCoroutineContext, - block: suspend CoroutineScope.() -> Unit -) { - val pendingResult = goAsync() - @OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback. - GlobalScope.launch(context) { - try { - block() - } finally { - pendingResult.finish() - } - } -} - -fun BigDecimal.toThreeDecimalPlaceString(): String { - val df = DecimalFormat("#.###") - return df.format(this) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 8f21a59..7b4288a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -1,29 +1,32 @@ package com.zaneschepke.wireguardautotunnel import android.app.Application -import android.content.Context +import android.content.ComponentName import android.content.pm.PackageManager +import android.service.quicksettings.TileService import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager -import com.zaneschepke.wireguardautotunnel.repository.model.Settings +import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile import dagger.hilt.android.HiltAndroidApp -import java.io.IOException -import javax.inject.Inject import kotlinx.coroutines.launch import timber.log.Timber +import java.io.IOException +import javax.inject.Inject @HiltAndroidApp class WireGuardAutoTunnel : Application() { @Inject - lateinit var settingsRepo: SettingsDoa + lateinit var settingsRepository: SettingsRepository @Inject lateinit var dataStoreManager: DataStoreManager override fun onCreate() { super.onCreate() + instance = this if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) initSettings() with(ProcessLifecycleOwner.get()) { @@ -31,6 +34,7 @@ class WireGuardAutoTunnel : Application() { try { // load preferences into memory dataStoreManager.init() + requestTileServiceStateUpdate() } catch (e: IOException) { Timber.e("Failed to load preferences") } @@ -41,16 +45,20 @@ class WireGuardAutoTunnel : Application() { private fun initSettings() { with(ProcessLifecycleOwner.get()) { lifecycleScope.launch { - if (settingsRepo.getAll().isEmpty()) { - settingsRepo.save(Settings()) + if (settingsRepository.getAll().isEmpty()) { + settingsRepository.save(Settings()) } } } } companion object { - fun isRunningOnAndroidTv(context: Context): Boolean { - return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + lateinit var instance: WireGuardAutoTunnel private set + fun isRunningOnAndroidTv(): Boolean { + return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + } + fun requestTileServiceStateUpdate() { + TileService.requestListeningState(instance, ComponentName(instance, TunnelControlTile::class.java)) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/AppDatabase.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt similarity index 64% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/AppDatabase.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt index 7b83086..666f813 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -1,26 +1,29 @@ -package com.zaneschepke.wireguardautotunnel.repository +package com.zaneschepke.wireguardautotunnel.data import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.zaneschepke.wireguardautotunnel.repository.model.Settings -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig @Database( entities = [Settings::class, TunnelConfig::class], - version = 4, + version = 5, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration( from = 3, to = 4 + ),AutoMigration( + from = 4, + to = 5 ) ], exportSchema = true ) @TypeConverters(DatabaseListConverters::class) abstract class AppDatabase : RoomDatabase() { - abstract fun settingDao(): SettingsDoa + abstract fun settingDao(): SettingsDao abstract fun tunnelConfigDoa(): TunnelConfigDao } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/DatabaseListConverters.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseListConverters.kt similarity index 92% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/DatabaseListConverters.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseListConverters.kt index d6c2af9..dc44129 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/DatabaseListConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseListConverters.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.repository +package com.zaneschepke.wireguardautotunnel.data import androidx.room.TypeConverter import kotlinx.serialization.encodeToString diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsDoa.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/SettingsDao.kt similarity index 77% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsDoa.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/SettingsDao.kt index fbf116a..ffd1574 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsDoa.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/SettingsDao.kt @@ -1,15 +1,15 @@ -package com.zaneschepke.wireguardautotunnel.repository +package com.zaneschepke.wireguardautotunnel.data import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.zaneschepke.wireguardautotunnel.repository.model.Settings +import com.zaneschepke.wireguardautotunnel.data.model.Settings import kotlinx.coroutines.flow.Flow @Dao -interface SettingsDoa { +interface SettingsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(t: Settings) @@ -22,6 +22,9 @@ interface SettingsDoa { @Query("SELECT * FROM settings") suspend fun getAll(): List + @Query("SELECT * FROM settings LIMIT 1") + fun getSettingsFlow(): Flow + @Query("SELECT * FROM settings") fun getAllFlow(): Flow> diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelConfigDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/TunnelConfigDao.kt similarity index 86% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelConfigDao.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/TunnelConfigDao.kt index 9040fca..8460ace 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelConfigDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/TunnelConfigDao.kt @@ -1,11 +1,11 @@ -package com.zaneschepke.wireguardautotunnel.repository +package com.zaneschepke.wireguardautotunnel.data import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/datastore/DataStoreManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/datastore/DataStoreManager.kt similarity index 80% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/datastore/DataStoreManager.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/datastore/DataStoreManager.kt index f282c8f..6d9c67c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/datastore/DataStoreManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/datastore/DataStoreManager.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.repository.datastore +package com.zaneschepke.wireguardautotunnel.data.datastore import android.content.Context import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -27,12 +27,12 @@ class DataStoreManager(private val context: Context) { context.dataStore.edit { it[key] = value } - - fun getFromStore(key: Preferences.Key) = - context.dataStore.data.map { + fun getFromStoreFlow(key: Preferences.Key) = context.dataStore.data.map { it[key] } + suspend fun getFromStore(key: Preferences.Key) = context.dataStore.data.first { it.contains(key) }[key] + val locationDisclosureFlow: Flow = context.dataStore.data.map { it[LOCATION_DISCLOSURE_SHOWN] } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Settings.kt similarity index 88% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Settings.kt index c44ace0..d92c74e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/Settings.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.repository.model +package com.zaneschepke.wireguardautotunnel.data.model import androidx.room.ColumnInfo import androidx.room.Entity @@ -36,7 +36,11 @@ data class Settings( @ColumnInfo( name = "is_multi_tunnel_enabled", defaultValue = "false" - ) var isMultiTunnelEnabled: Boolean = false + ) var isMultiTunnelEnabled: Boolean = false, + @ColumnInfo( + name = "is_auto_tunnel_paused", + defaultValue = "false" + ) var isAutoTunnelPaused: Boolean = false, ) { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean { return if (defaultTunnel != null) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/TunnelConfig.kt similarity index 94% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/TunnelConfig.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/TunnelConfig.kt index 2234368..acb348e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/TunnelConfig.kt @@ -1,13 +1,13 @@ -package com.zaneschepke.wireguardautotunnel.repository.model +package com.zaneschepke.wireguardautotunnel.data.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import com.wireguard.config.Config -import java.io.InputStream import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import java.io.InputStream @Entity(indices = [Index(value = ["name"], unique = true)]) @Serializable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..685fe5b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepository.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.wireguardautotunnel.data.repository + +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import kotlinx.coroutines.flow.Flow + +interface SettingsRepository { + suspend fun save(settings : Settings) + fun getSettingsFlow() : Flow + + suspend fun getSettings() : Settings + suspend fun getAll() : List +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepositoryImpl.kt new file mode 100644 index 0000000..2c47662 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/SettingsRepositoryImpl.kt @@ -0,0 +1,24 @@ +package com.zaneschepke.wireguardautotunnel.data.repository + +import com.zaneschepke.wireguardautotunnel.data.SettingsDao +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import kotlinx.coroutines.flow.Flow + +class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRepository { + + override suspend fun save(settings: Settings) { + settingsDoa.save(settings) + } + + override fun getSettingsFlow(): Flow { + return settingsDoa.getSettingsFlow() + } + + override suspend fun getSettings(): Settings { + return settingsDoa.getAll().firstOrNull() ?: Settings() + } + + override suspend fun getAll(): List { + return settingsDoa.getAll() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepository.kt new file mode 100644 index 0000000..f0c094d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepository.kt @@ -0,0 +1,14 @@ +package com.zaneschepke.wireguardautotunnel.data.repository + +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs +import kotlinx.coroutines.flow.Flow + +interface TunnelConfigRepository { + + fun getTunnelConfigsFlow() : Flow + suspend fun getAll() : TunnelConfigs + suspend fun save(tunnelConfig: TunnelConfig) + suspend fun delete(tunnelConfig: TunnelConfig) + suspend fun count() : Int +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepositoryImpl.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepositoryImpl.kt new file mode 100644 index 0000000..f9e3be7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/TunnelConfigRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.zaneschepke.wireguardautotunnel.data.repository + +import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs +import kotlinx.coroutines.flow.Flow + +class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository { + override fun getTunnelConfigsFlow(): Flow { + return tunnelConfigDao.getAllFlow() + } + + override suspend fun getAll(): TunnelConfigs { + return tunnelConfigDao.getAll() + } + + override suspend fun save(tunnelConfig: TunnelConfig) { + tunnelConfigDao.save(tunnelConfig) + } + + override suspend fun delete(tunnelConfig: TunnelConfig) { + tunnelConfigDao.delete(tunnelConfig) + } + + override suspend fun count(): Int { + return tunnelConfigDao.count().toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/DatabaseModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/DatabaseModule.kt index 057b820..4634ecb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/DatabaseModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/DatabaseModule.kt @@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.module import android.content.Context import androidx.room.Room import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.repository.AppDatabase +import com.zaneschepke.wireguardautotunnel.data.AppDatabase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt index b1ecb0b..453607a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt @@ -1,10 +1,14 @@ package com.zaneschepke.wireguardautotunnel.module import android.content.Context -import com.zaneschepke.wireguardautotunnel.repository.AppDatabase -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager +import com.zaneschepke.wireguardautotunnel.data.AppDatabase +import com.zaneschepke.wireguardautotunnel.data.SettingsDao +import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao +import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepositoryImpl +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,16 +21,28 @@ import javax.inject.Singleton class RepositoryModule { @Singleton @Provides - fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa { + fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao { return appDatabase.settingDao() } @Singleton @Provides - fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao { + fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao { return appDatabase.tunnelConfigDoa() } + @Singleton + @Provides + fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository { + return TunnelConfigRepositoryImpl(tunnelConfigDao) + } + + @Singleton + @Provides + fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository { + return SettingsRepositoryImpl(settingsDao) + } + @Singleton @Provides fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt index 7da9de2..d274ad0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt @@ -6,7 +6,7 @@ import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell import com.wireguard.android.util.ToolsInstaller -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import dagger.Module @@ -51,8 +51,8 @@ class TunnelModule { fun provideVpnService( @Userspace userspaceBackend: Backend, @Kernel kernelBackend: Backend, - settingsDoa: SettingsDoa + settingsRepository : SettingsRepository ): VpnService { - return WireGuardTunnel(userspaceBackend, kernelBackend, settingsDoa) + return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt index 6e037aa..288e84d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -3,34 +3,23 @@ package com.zaneschepke.wireguardautotunnel.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.zaneschepke.wireguardautotunnel.goAsync -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager +import com.zaneschepke.wireguardautotunnel.util.goAsync import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.cancel + @AndroidEntryPoint class BootReceiver : BroadcastReceiver() { - @Inject - lateinit var settingsRepo: SettingsDoa - override fun onReceive( - context: Context, - intent: Intent - ) = goAsync { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - try { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings.first() - if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { - ServiceManager.startWatcherService(context, setting.defaultTunnel!!) - } - } - } finally { - cancel() - } + @Inject + lateinit var settingsRepository: SettingsRepository + override fun onReceive(context: Context?, intent: Intent?) = goAsync { + if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync + if(settingsRepository.getSettings().isAutoTunnelEnabled) { + ServiceManager.startWatcherServiceForeground(context!!) } } + } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt index 66adb88..0b9ec14 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt @@ -3,33 +3,30 @@ package com.zaneschepke.wireguardautotunnel.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.zaneschepke.wireguardautotunnel.Constants -import com.zaneschepke.wireguardautotunnel.goAsync -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.goAsync import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import javax.inject.Inject @AndroidEntryPoint class NotificationActionReceiver : BroadcastReceiver() { @Inject - lateinit var settingsRepo: SettingsDoa + lateinit var settingsRepository: SettingsRepository override fun onReceive( context: Context, intent: Intent? ) = goAsync { try { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings.first() - if (setting.defaultTunnel != null) { - ServiceManager.stopVpnService(context) - delay(Constants.TOGGLE_TUNNEL_DELAY) - ServiceManager.startVpnService(context, setting.defaultTunnel.toString()) - } + val settings = settingsRepository.getSettings() + if (settings.defaultTunnel != null) { + ServiceManager.stopVpnService(context) + delay(Constants.TOGGLE_TUNNEL_DELAY) + ServiceManager.startVpnService(context, settings.defaultTunnel.toString()) } } finally { cancel() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt index 1d89552..d6b2224 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt @@ -89,35 +89,23 @@ object ServiceManager { ) } - private fun startWatcherServiceForeground( + fun startWatcherServiceForeground( context: Context, - tunnelConfig: String ) { actionOnService( - Action.START, + Action.START_FOREGROUND, context, - WireGuardConnectivityWatcherService::class.java, - mapOf( - context - .getString(R.string.tunnel_extras_key) to - tunnelConfig - ) + WireGuardConnectivityWatcherService::class.java ) } fun startWatcherService( - context: Context, - tunnelConfig: String + context: Context ) { actionOnService( Action.START, context, - WireGuardConnectivityWatcherService::class.java, - mapOf( - context - .getString(R.string.tunnel_extras_key) to - tunnelConfig - ) + WireGuardConnectivityWatcherService::class.java ) } @@ -128,19 +116,4 @@ object ServiceManager { WireGuardConnectivityWatcherService::class.java ) } - - fun toggleWatcherServiceForeground( - context: Context, - tunnelConfig: String - ) { - when ( - getServiceState( - context, - WireGuardConnectivityWatcherService::class.java - ) - ) { - ServiceState.STARTED -> stopWatcherService(context) - ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig) - } - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt index 3a2f7f9..8aa5e6a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -10,10 +10,9 @@ import android.os.SystemClock import androidx.core.app.ServiceCompat import androidx.lifecycle.lifecycleScope import com.wireguard.android.backend.Tunnel -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.model.Settings +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService @@ -21,315 +20,348 @@ import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus import com.zaneschepke.wireguardautotunnel.service.network.WifiService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService +import com.zaneschepke.wireguardautotunnel.util.Constants import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class WireGuardConnectivityWatcherService : ForegroundService() { - private val foregroundId = 122 + private val foregroundId = 122 - @Inject - lateinit var wifiService: NetworkService + @Inject lateinit var wifiService: NetworkService - @Inject - lateinit var mobileDataService: NetworkService + @Inject lateinit var mobileDataService: NetworkService - @Inject - lateinit var ethernetService: NetworkService + @Inject lateinit var ethernetService: NetworkService - @Inject - lateinit var settingsRepo: SettingsDoa + @Inject lateinit var settingsRepository: SettingsRepository - @Inject - lateinit var notificationService: NotificationService + @Inject lateinit var notificationService: NotificationService - @Inject - lateinit var vpnService: VpnService + @Inject lateinit var vpnService: VpnService - private var isWifiConnected = false - private var isEthernetConnected = false - private var isMobileDataConnected = false - private var currentNetworkSSID = "" + private val networkEventsFlow = MutableStateFlow(WatcherState()) + data class WatcherState( + val isWifiConnected: Boolean = false, + val isVpnConnected : Boolean = false, + val isEthernetConnected: Boolean = false, + val isMobileDataConnected: Boolean = false, + val currentNetworkSSID: String = "", + val settings: Settings = Settings() + ) - private lateinit var watcherJob: Job - private lateinit var setting: Settings - private lateinit var tunnelConfig: String + private lateinit var watcherJob: Job - private var wakeLock: PowerManager.WakeLock? = null - private val tag = this.javaClass.name + private var wakeLock: PowerManager.WakeLock? = null + private val tag = this.javaClass.name - override fun onCreate() { - super.onCreate() - lifecycleScope.launch(Dispatchers.Main) { - try { - launchWatcherNotification() - } catch (e: Exception) { - Timber.e("Failed to start watcher service, not enough permissions") - } - } + override fun onCreate() { + super.onCreate() + lifecycleScope.launch(Dispatchers.Main) { + try { + if(settingsRepository.getSettings().isAutoTunnelPaused) { + launchWatcherPausedNotification() + } else launchWatcherNotification() + } catch (e: Exception) { + Timber.e("Failed to start watcher service, not enough permissions") + } + } + } + + override fun startService(extras: Bundle?) { + super.startService(extras) + try { + // we need this lock so our service gets not affected by Doze Mode + lifecycleScope.launch { initWakeLock() } + cancelWatcherJob() + startWatcherJob() + } catch (e: Exception) { + Timber.e("Failed to launch watcher service, no permissions") + } + } + + override fun stopService(extras: Bundle?) { + super.stopService(extras) + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + cancelWatcherJob() + stopSelf() + } + + private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) { + val notification = + notificationService.createNotification( + channelId = getString(R.string.watcher_channel_id), + channelName = getString(R.string.watcher_channel_name), + title = getString(R.string.auto_tunnel_title), + description = description) + ServiceCompat.startForeground( + this, foregroundId, notification, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID) + } + + private fun launchWatcherPausedNotification() { + launchWatcherNotification(getString(R.string.watcher_notification_text_paused)) } - override fun startService(extras: Bundle?) { - super.startService(extras) - try { - launchWatcherNotification() - val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) - if (tunnelId != null) { - this.tunnelConfig = tunnelId - } - // we need this lock so our service gets not affected by Doze Mode - lifecycleScope.launch { - initWakeLock() - } - cancelWatcherJob() - if (this::tunnelConfig.isInitialized) { - startWatcherJob() - } else { - stopService(extras) - } - } catch (e: Exception) { - Timber.e("Failed to launch watcher service, no permissions") - } - } - - override fun stopService(extras: Bundle?) { - super.stopService(extras) - wakeLock?.let { - if (it.isHeld) { - it.release() - } - } - cancelWatcherJob() - stopSelf() - } - - private fun launchWatcherNotification() { - val notification = - notificationService.createNotification( - channelId = getString(R.string.watcher_channel_id), - channelName = getString(R.string.watcher_channel_name), - description = getString(R.string.watcher_notification_text), - vibration = false - ) - ServiceCompat.startForeground( + // TODO could this be restarting service in a bad state? + // try to start task again if killed + override fun onTaskRemoved(rootIntent: Intent) { + Timber.d("Task Removed called") + val restartServiceIntent = Intent(rootIntent) + val restartServicePendingIntent: PendingIntent = + PendingIntent.getService( this, - foregroundId, - notification, - Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID - ) - } + 1, + restartServiceIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE) + applicationContext.getSystemService(Context.ALARM_SERVICE) + val alarmService: AlarmManager = + applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmService.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + restartServicePendingIntent) + } - // try to start task again if killed - override fun onTaskRemoved(rootIntent: Intent) { - Timber.d("Task Removed called") - val restartServiceIntent = Intent(rootIntent) - val restartServicePendingIntent: PendingIntent = - PendingIntent.getService( - this, - 1, - restartServiceIntent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + private suspend fun initWakeLock() { + val isBatterySaverOn = + withContext(lifecycleScope.coroutineContext) { + settingsRepository.getSettings().isBatterySaverEnabled + } + wakeLock = + (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { + if (isBatterySaverOn) { + Timber.d("Initiating wakelock with timeout") + acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT) + } else { + Timber.d("Initiating wakelock with zero timeout") + acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT) + } + } + } + } + + private fun cancelWatcherJob() { + if (this::watcherJob.isInitialized) { + watcherJob.cancel() + } + } + + private fun startWatcherJob() { + watcherJob = + lifecycleScope.launch(Dispatchers.IO) { + val setting = settingsRepository.getSettings() + launch { + Timber.d("Starting wifi watcher") + watchForWifiConnectivityChanges() + } + if (setting.isTunnelOnMobileDataEnabled) { + launch { + Timber.d("Starting mobile data watcher") + watchForMobileDataConnectivityChanges() + } + } + if (setting.isTunnelOnEthernetEnabled) { + launch { + Timber.d("Starting ethernet data watcher") + watchForEthernetConnectivityChanges() + } + } + launch { + Timber.d("Starting vpn state watcher") + watchForVpnConnectivityChanges() + } + launch { + Timber.d("Starting settings watcher") + watchForSettingsChanges() + } + launch { + Timber.d("Starting management watcher") + manageVpn() + } + } + } + + private suspend fun watchForMobileDataConnectivityChanges() { + mobileDataService.networkStatus.collect { + when (it) { + is NetworkStatus.Available -> { + Timber.d("Gained Mobile data connection") + networkEventsFlow.value = networkEventsFlow.value.copy( + isMobileDataConnected = true ) - applicationContext.getSystemService(Context.ALARM_SERVICE) - val alarmService: AlarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmService.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 1000, - restartServicePendingIntent - ) + } + is NetworkStatus.CapabilitiesChanged -> { + networkEventsFlow.value = networkEventsFlow.value.copy( + isMobileDataConnected = true + ) + Timber.d("Mobile data capabilities changed") + } + is NetworkStatus.Unavailable -> { + networkEventsFlow.value = networkEventsFlow.value.copy( + isMobileDataConnected = false + ) + Timber.d("Lost mobile data connection") + } + } } - - private suspend fun initWakeLock() { - val isBatterySaverOn = - withContext(lifecycleScope.coroutineContext) { - settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false - } - wakeLock = - (getSystemService(Context.POWER_SERVICE) as PowerManager).run { - newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { - if (isBatterySaverOn) { - Timber.d("Initiating wakelock with timeout") - acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT) - } else { - Timber.d("Initiating wakelock with zero timeout") - acquire() - } + } + private suspend fun watchForSettingsChanges() { + settingsRepository.getSettingsFlow().collect { + if(networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) { + when(it.isAutoTunnelPaused) { + true -> launchWatcherPausedNotification() + false -> launchWatcherNotification() } } - } - - private fun cancelWatcherJob() { - if (this::watcherJob.isInitialized) { - watcherJob.cancel() + networkEventsFlow.value = networkEventsFlow.value.copy( + settings = it + ) } } - private fun startWatcherJob() { - watcherJob = - lifecycleScope.launch(Dispatchers.IO) { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - setting = settings[0] - } - launch { - watchForWifiConnectivityChanges() - } - if (setting.isTunnelOnMobileDataEnabled) { - launch { - watchForMobileDataConnectivityChanges() - } - } - if (setting.isTunnelOnEthernetEnabled) { - launch { - watchForEthernetConnectivityChanges() - } - } - launch { - manageVpn() - } - } - } - - private suspend fun watchForMobileDataConnectivityChanges() { - mobileDataService.networkStatus.collect { - when (it) { - is NetworkStatus.Available -> { - Timber.d("Gained Mobile data connection") - isMobileDataConnected = true - } - - is NetworkStatus.CapabilitiesChanged -> { - isMobileDataConnected = true - Timber.d("Mobile data capabilities changed") - } - - is NetworkStatus.Unavailable -> { - isMobileDataConnected = false - Timber.d("Lost mobile data connection") - } + private suspend fun watchForVpnConnectivityChanges() { + vpnService.vpnState.collect { + when(it.status) { + Tunnel.State.DOWN -> networkEventsFlow.value = networkEventsFlow.value.copy( + isVpnConnected = false + ) + Tunnel.State.UP -> networkEventsFlow.value = networkEventsFlow.value.copy( + isVpnConnected = true + ) + else -> {} } } } - private suspend fun watchForEthernetConnectivityChanges() { - ethernetService.networkStatus.collect { - when (it) { - is NetworkStatus.Available -> { - Timber.d("Gained Ethernet connection") - isEthernetConnected = true - } - - is NetworkStatus.CapabilitiesChanged -> { - Timber.d("Ethernet capabilities changed") - isEthernetConnected = true - } - - is NetworkStatus.Unavailable -> { - isEthernetConnected = false - Timber.d("Lost Ethernet connection") - } - } + private suspend fun watchForEthernetConnectivityChanges() { + ethernetService.networkStatus.collect { + when (it) { + is NetworkStatus.Available -> { + Timber.d("Gained Ethernet connection") + networkEventsFlow.value = networkEventsFlow.value.copy( + isEthernetConnected = true + ) } - } - - private suspend fun watchForWifiConnectivityChanges() { - wifiService.networkStatus.collect { - when (it) { - is NetworkStatus.Available -> { - Timber.d("Gained Wi-Fi connection") - isWifiConnected = true - } - - is NetworkStatus.CapabilitiesChanged -> { - Timber.d("Wifi capabilities changed") - isWifiConnected = true - val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: "" - Timber.d("Detected SSID: $ssid") - currentNetworkSSID = ssid - } - - is NetworkStatus.Unavailable -> { - isWifiConnected = false - Timber.d("Lost Wi-Fi connection") - } - } + is NetworkStatus.CapabilitiesChanged -> { + Timber.d("Ethernet capabilities changed") + networkEventsFlow.value = networkEventsFlow.value.copy( + isEthernetConnected = true + ) } + is NetworkStatus.Unavailable -> { + networkEventsFlow.value = networkEventsFlow.value.copy( + isEthernetConnected = false + ) + Timber.d("Lost Ethernet connection") + } + } } + } - private suspend fun manageVpn() { - while (true) { + private suspend fun watchForWifiConnectivityChanges() { + wifiService.networkStatus.collect { + when (it) { + is NetworkStatus.Available -> { + Timber.d("Gained Wi-Fi connection") + networkEventsFlow.value = networkEventsFlow.value.copy( + isWifiConnected = true + ) + } + is NetworkStatus.CapabilitiesChanged -> { + Timber.d("Wifi capabilities changed") + networkEventsFlow.value = networkEventsFlow.value.copy( + isWifiConnected = true + ) + val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: "" + Timber.d("Detected SSID: $ssid") + networkEventsFlow.value = networkEventsFlow.value.copy( + currentNetworkSSID = ssid + ) + } + is NetworkStatus.Unavailable -> { + networkEventsFlow.value = networkEventsFlow.value.copy( + isWifiConnected = false + ) + Timber.d("Lost Wi-Fi connection") + } + } + } + } + + //TODO clean this up + private suspend fun manageVpn() { + networkEventsFlow.collectLatest { + Timber.i("New watcher state: $it") + if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) { + delay(Constants.TOGGLE_TUNNEL_DELAY) when { - ( - ( - isEthernetConnected && - setting.isTunnelOnEthernetEnabled && - vpnService.getState() == Tunnel.State.DOWN - ) - ) -> - ServiceManager.startVpnService(this, tunnelConfig) - - ( - !isEthernetConnected && - setting.isTunnelOnMobileDataEnabled && - !isWifiConnected && - isMobileDataConnected && - vpnService.getState() == Tunnel.State.DOWN - ) -> - ServiceManager.startVpnService(this, tunnelConfig) - - ( - !isEthernetConnected && - !setting.isTunnelOnMobileDataEnabled && - !isWifiConnected && - vpnService.getState() == Tunnel.State.UP - ) -> + ((it.isEthernetConnected && + it.settings.isTunnelOnEthernetEnabled && + !it.isVpnConnected)) -> { + ServiceManager.startVpnService(this, it.settings.defaultTunnel!!) + Timber.i("Condition 1 met") + } + (!it.isEthernetConnected && + it.settings.isTunnelOnMobileDataEnabled && + !it.isWifiConnected && + it.isMobileDataConnected && + !it.isVpnConnected) -> { + ServiceManager.startVpnService(this, it.settings.defaultTunnel!!) + Timber.i("Condition 2 met") + } + (!it.isEthernetConnected && + !it.settings.isTunnelOnMobileDataEnabled && + !it.isWifiConnected && + it.isVpnConnected) -> { ServiceManager.stopVpnService(this) - - ( - !isEthernetConnected && isWifiConnected && - !setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && - setting.isTunnelOnWifiEnabled && - (vpnService.getState() != Tunnel.State.UP) - ) -> - ServiceManager.startVpnService(this, tunnelConfig) - - ( - !isEthernetConnected && ( - isWifiConnected && - setting.trustedNetworkSSIDs.contains(currentNetworkSSID) - ) && - (vpnService.getState() == Tunnel.State.UP) - ) -> + Timber.i("Condition 3 met") + } + (!it.isEthernetConnected && + it.isWifiConnected && + !it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) && + it.settings.isTunnelOnWifiEnabled && + (!it.isVpnConnected)) -> { + ServiceManager.startVpnService(this, it.settings.defaultTunnel!!) + Timber.i("Condition 4 met") + } + (!it.isEthernetConnected && + (it.isWifiConnected && it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) && + (it.isVpnConnected)) -> { ServiceManager.stopVpnService(this) - - ( - !isEthernetConnected && ( - isWifiConnected && - !setting.isTunnelOnWifiEnabled && - (vpnService.getState() == Tunnel.State.UP) - ) - ) -> + Timber.i("Condition 5 met") + } + (!it.isEthernetConnected && + (it.isWifiConnected && + !it.settings.isTunnelOnWifiEnabled && + (it.isVpnConnected))) -> { ServiceManager.stopVpnService(this) - - ( - !isEthernetConnected && !isWifiConnected && - !isMobileDataConnected && - (vpnService.getState() == Tunnel.State.UP) - ) -> + Timber.i("Condition 6 met") + } + (!it.isEthernetConnected && + !it.isWifiConnected && + !it.isMobileDataConnected && + (it.isVpnConnected)) -> { ServiceManager.stopVpnService(this) - + Timber.i("Condition 7 met") + } else -> { - // Do nothing + Timber.i("No condition met") } } - delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL) } } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt index e53ee6a..62641b9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt @@ -5,20 +5,24 @@ import android.content.Intent import android.os.Bundle import androidx.core.app.ServiceCompat import androidx.lifecycle.lifecycleScope -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.handshakeStatus +import com.zaneschepke.wireguardautotunnel.util.mapPeerStats import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class WireGuardTunnelService : ForegroundService() { @@ -28,7 +32,10 @@ class WireGuardTunnelService : ForegroundService() { lateinit var vpnService: VpnService @Inject - lateinit var settingsRepo: SettingsDoa + lateinit var settingsRepository: SettingsRepository + + @Inject + lateinit var tunnelConfigRepository: TunnelConfigRepository @Inject lateinit var notificationService: NotificationService @@ -36,26 +43,29 @@ class WireGuardTunnelService : ForegroundService() { private lateinit var job: Job private var tunnelName: String = "" + private var didShowConnected = false override fun onCreate() { super.onCreate() lifecycleScope.launch(Dispatchers.Main) { - launchVpnStartingNotification() + if(tunnelConfigRepository.getAll().isNotEmpty()) { + launchVpnNotification() + } } } override fun startService(extras: Bundle?) { super.startService(extras) - // TODO fix grapheneOS calls always-on on install - launchVpnStartingNotification() - val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) cancelJob() - job = - lifecycleScope.launch(Dispatchers.IO) { + val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) + val tunnelConfig = tunnelConfigString?.let { + TunnelConfig.from(it) + } + tunnelName = tunnelConfig?.name ?: "" + job = lifecycleScope.launch(Dispatchers.IO) { launch { - if (tunnelConfigString != null) { + if (tunnelConfig != null) { try { - val tunnelConfig = TunnelConfig.from(tunnelConfigString) tunnelName = tunnelConfig.name vpnService.startTunnel(tunnelConfig) } catch (e: Exception) { @@ -63,52 +73,45 @@ class WireGuardTunnelService : ForegroundService() { stopService(extras) } } else { - Timber.d("Tunnel config null, starting default tunnel") - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings[0] - if (setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { - val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!) - tunnelName = tunnelConfig.name - vpnService.startTunnel(tunnelConfig) + Timber.d("Tunnel config null, starting default tunnel or first") + val settings = settingsRepository.getSettings() + val tunnels = tunnelConfigRepository.getAll() + if (settings.isAlwaysOnVpnEnabled) { + val tunnel = if(settings.defaultTunnel != null) { + TunnelConfig.from(settings.defaultTunnel!!) + } else if(tunnels.isNotEmpty()) { + tunnels.first() + } else { + null } + if(tunnel != null) { + tunnelName = tunnel.name + vpnService.startTunnel(tunnel) + } + } } } + //TODO add failed to connect notification launch { - var didShowConnected = false - var didShowFailedHandshakeNotification = false - vpnService.handshakeStatus.collect { - when (it) { - HandshakeStatus.NOT_STARTED -> { - } - HandshakeStatus.NEVER_CONNECTED -> { - if (!didShowFailedHandshakeNotification) { - launchVpnConnectionFailedNotification( - getString(R.string.initial_connection_failure_message) - ) - didShowFailedHandshakeNotification = true - didShowConnected = false + vpnService.vpnState.collect { state -> + state.statistics + ?.mapPeerStats() + ?.map { it.value?.handshakeStatus() } + .let { statuses -> + when { + statuses?.all { it == HandshakeStatus.HEALTHY } == true -> { + if(!didShowConnected){ + delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY) + launchVpnNotification(getString(R.string.tunnel_start_title),"${getString(R.string.tunnel_start_text)} $tunnelName") + didShowConnected = true + } + } + statuses?.any { it == HandshakeStatus.STALE } == true -> {} + statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> {} + else -> {} } } - - HandshakeStatus.HEALTHY -> { - if (!didShowConnected) { - launchVpnConnectedNotification() - didShowConnected = true - } - } - HandshakeStatus.STALE -> {} - HandshakeStatus.UNHEALTHY -> { - if (!didShowFailedHandshakeNotification) { - launchVpnConnectionFailedNotification( - getString(R.string.lost_connection_failure_message) - ) - didShowFailedHandshakeNotification = true - didShowConnected = false - } - } - } } } } @@ -118,40 +121,22 @@ class WireGuardTunnelService : ForegroundService() { super.stopService(extras) lifecycleScope.launch(Dispatchers.IO) { vpnService.stopTunnel() + didShowConnected = false } cancelJob() stopSelf() } - private fun launchVpnConnectedNotification() { + private fun launchVpnNotification(title : String = getString(R.string.vpn_starting),description : String = getString(R.string.attempt_connection)) { val notification = notificationService.createNotification( channelId = getString(R.string.vpn_channel_id), channelName = getString(R.string.vpn_channel_name), - title = getString(R.string.tunnel_start_title), + title = title, onGoing = false, vibration = false, showTimestamp = true, - description = "${getString(R.string.tunnel_start_text)} $tunnelName" - ) - ServiceCompat.startForeground( - this, - foregroundId, - notification, - Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID - ) - } - - private fun launchVpnStartingNotification() { - val notification = - notificationService.createNotification( - channelId = getString(R.string.vpn_channel_id), - channelName = getString(R.string.vpn_channel_name), - title = getString(R.string.vpn_starting), - onGoing = false, - vibration = false, - showTimestamp = true, - description = getString(R.string.attempt_connection) + description = description ) ServiceCompat.startForeground( this, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt index 9058fea..005f1e6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.notification import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent +import androidx.core.app.NotificationCompat interface NotificationService { fun createNotification( @@ -16,6 +17,7 @@ interface NotificationService { importance: Int = NotificationManager.IMPORTANCE_HIGH, vibration: Boolean = false, onGoing: Boolean = true, - lights: Boolean = true + lights: Boolean = true, + onlyAlertOnce: Boolean = true, ): Notification } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt index 38c7d20..3180319 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Color +import androidx.core.app.NotificationCompat import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.MainActivity import dagger.hilt.android.qualifiers.ApplicationContext @@ -20,6 +21,16 @@ constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val watcherBuilder: NotificationCompat.Builder = + NotificationCompat.Builder( + context, + context.getString(R.string.watcher_channel_id) + ) + private val tunnelBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + context.getString(R.string.vpn_channel_id) + ) + override fun createNotification( channelId: String, channelName: String, @@ -31,7 +42,8 @@ constructor( importance: Int, vibration: Boolean, onGoing: Boolean, - lights: Boolean + lights: Boolean, + onlyAlertOnce: Boolean, ): Notification { val channel = NotificationChannel( @@ -43,7 +55,7 @@ constructor( it.enableLights(lights) it.lightColor = Color.RED it.enableVibration(vibration) - it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) + it.vibrationPattern = longArrayOf(100,200,300) it } notificationManager.createNotificationChannel(channel) @@ -57,24 +69,31 @@ constructor( ) } - val builder: Notification.Builder = - Notification.Builder( - context, - channelId - ) + val builder = when(channelId) { + context.getString(R.string.watcher_channel_id) -> watcherBuilder + context.getString(R.string.vpn_channel_id) -> tunnelBuilder + else -> { + NotificationCompat.Builder( + context, + channelId + ) + } + } + return builder.let { if (action != null && actionText != null) { - // TODO find a not deprecated way to do this it.addAction( - Notification.Action.Builder(0, actionText, action) + NotificationCompat.Action.Builder(0, actionText, action) .build() ) it.setAutoCancel(true) } it.setContentTitle(title) .setContentText(description) + .setOnlyAlertOnce(onlyAlertOnce) .setContentIntent(pendingIntent) .setOngoing(onGoing) + .setPriority(NotificationCompat.PRIORITY_HIGH) .setShowWhen(showTimestamp) .setSmallIcon(R.mipmap.ic_launcher_foreground) .build() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt index ac48f3c..1b06d8d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt @@ -1,61 +1,63 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut import android.os.Bundle +import android.view.View import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.model.Settings -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class ShortcutsActivity : ComponentActivity() { @Inject - lateinit var settingsRepo: SettingsDoa + lateinit var settingsRepository: SettingsRepository @Inject - lateinit var tunnelConfigRepo: TunnelConfigDao + lateinit var tunnelConfigRepository: TunnelConfigRepository - private fun attemptWatcherServiceToggle(tunnelConfig: String) { - lifecycleScope.launch(Dispatchers.Main) { - val settings = getSettings() + private suspend fun toggleWatcherServicePause() { + val settings = settingsRepository.getSettings() if (settings.isAutoTunnelEnabled) { - ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) + val pauseAutoTunnel = !settings.isAutoTunnelPaused + settingsRepository.save(settings.copy( + isAutoTunnelPaused = pauseAutoTunnel + )) } } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContentView(View(this)) if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) .equals(WireGuardTunnelService::class.java.simpleName) ) { lifecycleScope.launch(Dispatchers.Main) { - val settings = getSettings() + val settings = settingsRepository.getSettings() if (settings.isShortcutsEnabled) { try { val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelConfig = if (tunnelName != null) { - tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName } + tunnelConfigRepository.getAll().firstOrNull { it.name == tunnelName } } else { if (settings.defaultTunnel == null) { - tunnelConfigRepo.getAll().first() + tunnelConfigRepository.getAll().first() } else { TunnelConfig.from(settings.defaultTunnel!!) } } tunnelConfig ?: return@launch - attemptWatcherServiceToggle(tunnelConfig.toString()) + toggleWatcherServicePause() when (intent.action) { Action.STOP.name -> ServiceManager.stopVpnService( this@ShortcutsActivity @@ -67,6 +69,7 @@ class ShortcutsActivity : ComponentActivity() { } } catch (e: Exception) { Timber.e(e.message) + finish() } } } @@ -74,15 +77,6 @@ class ShortcutsActivity : ComponentActivity() { finish() } - private suspend fun getSettings(): Settings { - val settings = settingsRepo.getAll() - return if (settings.isNotEmpty()) { - settings.first() - } else { - throw WgTunnelException("Settings empty") - } - } - companion object { const val TUNNEL_NAME_EXTRA_KEY = "tunnelName" const val CLASS_NAME_EXTRA_KEY = "className" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt index dc4b3f7..efc4a9d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt @@ -4,51 +4,67 @@ import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import com.wireguard.android.backend.Tunnel -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint -class TunnelControlTile : TileService() { - @Inject - lateinit var settingsRepo: SettingsDoa +class TunnelControlTile() : TileService() { @Inject - lateinit var configRepo: TunnelConfigDao + lateinit var tunnelConfigRepository: TunnelConfigRepository + + @Inject + lateinit var settingsRepository: SettingsRepository @Inject lateinit var vpnService: VpnService - private val scope = CoroutineScope(Dispatchers.Main) + private val scope = CoroutineScope(Dispatchers.IO) - private lateinit var job: Job + private var tunnelName : String? = null override fun onStartListening() { - job = - scope.launch { - updateTileState() - } super.onStartListening() + Timber.d("On start listening called") + scope.launch { + vpnService.vpnState.collect { + when(it.status) { + Tunnel.State.UP -> setActive() + Tunnel.State.DOWN -> setInactive() + else -> setInactive() + } + val tunnels = tunnelConfigRepository.getAll() + if(tunnels.isEmpty()) { + setUnavailable() + return@collect + } + tunnelName = it.name.ifBlank { + val settings = settingsRepository.getSettings() + if (settings.defaultTunnel != null) { + TunnelConfig.from(settings.defaultTunnel!!).name + } else tunnels.firstOrNull()?.name + } + setTileDescription(tunnelName ?: "") + } + } + } + override fun onDestroy() { + super.onDestroy() + scope.cancel() } override fun onTileRemoved() { super.onTileRemoved() - cancelJob() - } - - override fun onDestroy() { - super.onDestroy() scope.cancel() } @@ -57,17 +73,15 @@ class TunnelControlTile : TileService() { unlockAndRun { scope.launch { try { - val tunnel = determineTileTunnel() - if (tunnel != null) { - attemptWatcherServiceToggle(tunnel.toString()) - if (vpnService.getState() == Tunnel.State.UP) { - ServiceManager.stopVpnService(this@TunnelControlTile) - } else { - ServiceManager.startVpnServiceForeground( - this@TunnelControlTile, - tunnel.toString() - ) - } + val tunnelConfig = tunnelConfigRepository.getAll().first { it.name == tunnelName } + toggleWatcherServicePause() + if (vpnService.getState() == Tunnel.State.UP) { + ServiceManager.stopVpnService(this@TunnelControlTile) + } else { + ServiceManager.startVpnServiceForeground( + this@TunnelControlTile, + tunnelConfig.toString() + ) } } catch (e: Exception) { Timber.e(e.message) @@ -78,68 +92,31 @@ class TunnelControlTile : TileService() { } } - private suspend fun determineTileTunnel(): TunnelConfig? { - var tunnelConfig: TunnelConfig? = null - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings.first() - tunnelConfig = - if (setting.defaultTunnel != null) { - TunnelConfig.from(setting.defaultTunnel!!) - } else { - val configs = configRepo.getAll() - val config = - if (configs.isNotEmpty()) { - configs.first() - } else { - null - } - config - } - } - return tunnelConfig - } - - private fun attemptWatcherServiceToggle(tunnelConfig: String) { + private fun toggleWatcherServicePause() { scope.launch { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings.first() - if (setting.isAutoTunnelEnabled) { - ServiceManager.toggleWatcherServiceForeground( - this@TunnelControlTile, - tunnelConfig - ) - } + val settings = settingsRepository.getSettings() + if (settings.isAutoTunnelEnabled) { + val pauseAutoTunnel = !settings.isAutoTunnelPaused + settingsRepository.save(settings.copy( + isAutoTunnelPaused = pauseAutoTunnel + )) } } } - private suspend fun updateTileState() { - vpnService.state.collect { - try { - when (it) { - Tunnel.State.UP -> { - qsTile.state = Tile.STATE_ACTIVE - } + private fun setActive() { + qsTile.state = Tile.STATE_ACTIVE + qsTile.updateTile() + } - Tunnel.State.DOWN -> { - qsTile.state = Tile.STATE_INACTIVE - } + private fun setInactive() { + qsTile.state = Tile.STATE_INACTIVE + qsTile.updateTile() + } - else -> { - qsTile.state = Tile.STATE_UNAVAILABLE - } - } - val config = determineTileTunnel() - setTileDescription( - config?.name ?: this.resources.getString(R.string.no_tunnel_available) - ) - qsTile.updateTile() - } catch (e: Exception) { - Timber.e("Unable to update tile state") - } - } + private fun setUnavailable() { + qsTile.state = Tile.STATE_UNAVAILABLE + qsTile.updateTile() } private fun setTileDescription(description: String) { @@ -149,11 +126,6 @@ class TunnelControlTile : TileService() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { qsTile.stateDescription = description } - } - - private fun cancelJob() { - if (this::job.isInitialized) { - job.cancel() - } + qsTile.updateTile() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt index a1b59c4..87621cc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt @@ -3,8 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel enum class HandshakeStatus { HEALTHY, STALE, - UNHEALTHY, - NEVER_CONNECTED, + UNKNOWN, NOT_STARTED ; diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt index 600fb2c..90e5ae1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt @@ -1,21 +1,15 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel -import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Tunnel -import com.wireguard.crypto.Key -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig -import kotlinx.coroutines.flow.SharedFlow +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import kotlinx.coroutines.flow.StateFlow interface VpnService : Tunnel { suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun stopTunnel() - val state: SharedFlow - val tunnelName: SharedFlow - val statistics: SharedFlow - val lastHandshake: SharedFlow> - val handshakeStatus: SharedFlow + val vpnState: StateFlow fun getState(): Tunnel.State } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnState.kt new file mode 100644 index 0000000..077f7ad --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnState.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.wireguardautotunnel.service.tunnel + +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Tunnel + +data class VpnState( + val status : Tunnel.State = Tunnel.State.DOWN, + val name : String = "", + val statistics : Statistics? = null +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt index ac05c49..53c039c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt @@ -3,60 +3,34 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel import com.wireguard.android.backend.Backend import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.Statistics -import com.wireguard.android.backend.Tunnel +import com.wireguard.android.backend.Tunnel.State import com.wireguard.config.Config -import com.wireguard.crypto.Key -import com.zaneschepke.wireguardautotunnel.Constants +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.module.Kernel import com.zaneschepke.wireguardautotunnel.module.Userspace -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import javax.inject.Inject +import com.zaneschepke.wireguardautotunnel.util.Constants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject class WireGuardTunnel @Inject constructor( @Userspace private val userspaceBackend: Backend, @Kernel private val kernelBackend: Backend, - private val settingsRepo: SettingsDoa + private val settingsRepository: SettingsRepository ) : VpnService { - private val _tunnelName = MutableStateFlow("") - override val tunnelName get() = _tunnelName.asStateFlow() - - private val _state = - MutableSharedFlow( - onBufferOverflow = BufferOverflow.DROP_OLDEST, - replay = 1 - ) - - private val _handshakeStatus = - MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val state get() = _state.asSharedFlow() - - private val _statistics = MutableSharedFlow(replay = 1) - override val statistics get() = _statistics.asSharedFlow() - - private val _lastHandshake = MutableSharedFlow>(replay = 1) - override val lastHandshake get() = _lastHandshake.asSharedFlow() - - override val handshakeStatus: SharedFlow - get() = _handshakeStatus.asSharedFlow() + private val _vpnState = MutableStateFlow(VpnState()) + override val vpnState: StateFlow = _vpnState.asStateFlow() private val scope = CoroutineScope(Dispatchers.IO) @@ -70,13 +44,12 @@ constructor( init { scope.launch { - settingsRepo.getAllFlow().collect { - val settings = it.first() - if (settings.isKernelEnabled && backendIsUserspace) { + settingsRepository.getSettingsFlow().collect { + if (it.isKernelEnabled && backendIsUserspace) { Timber.d("Setting kernel backend") backend = kernelBackend backendIsUserspace = false - } else if (!settings.isKernelEnabled && !backendIsUserspace) { + } else if (!it.isKernelEnabled && !backendIsUserspace) { Timber.d("Setting userspace backend") backend = userspaceBackend backendIsUserspace = true @@ -85,7 +58,7 @@ constructor( } } - override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State { + override suspend fun startTunnel(tunnelConfig: TunnelConfig): State { return try { stopTunnelOnConfigChange(tunnelConfig) emitTunnelName(tunnelConfig.name) @@ -93,95 +66,84 @@ constructor( val state = backend.setState( this, - Tunnel.State.UP, + State.UP, config ) - _state.emit(state) + emitTunnelState(state) state } catch (e: Exception) { Timber.e("Failed to start tunnel with error: ${e.message}") - Tunnel.State.DOWN + State.DOWN } } + private fun emitTunnelState(state: State) { + _vpnState.tryEmit( + _vpnState.value.copy( + status = state + ) + ) + } + + private fun emitBackendStatistics(statistics: Statistics) { + _vpnState.tryEmit( + _vpnState.value.copy( + statistics = statistics + ) + ) + } + private suspend fun emitTunnelName(name: String) { - _tunnelName.emit(name) + _vpnState.emit( + _vpnState.value.copy( + name = name + ) + ) } private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) { - if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { + if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) { stopTunnel() } } override fun getName(): String { - return _tunnelName.value + return _vpnState.value.name } override suspend fun stopTunnel() { try { - if (getState() == Tunnel.State.UP) { - val state = backend.setState(this, Tunnel.State.DOWN, null) - _state.emit(state) + if (getState() == State.UP) { + val state = backend.setState(this, State.DOWN, null) + emitTunnelState(state) } } catch (e: BackendException) { Timber.e("Failed to stop tunnel with error: ${e.message}") } } - override fun getState(): Tunnel.State { + override fun getState(): State { return backend.getState(this) } - override fun onStateChange(state: Tunnel.State) { + override fun onStateChange(state: State) { val tunnel = this - _state.tryEmit(state) - if (state == Tunnel.State.UP) { + emitTunnelState(state) + WireGuardAutoTunnel.requestTileServiceStateUpdate() + if (state == State.UP) { statsJob = scope.launch { - val handshakeMap = HashMap() - var neverHadHandshakeCounter = 0 while (true) { val statistics = backend.getStatistics(tunnel) - _statistics.emit(statistics) - statistics.peers().forEach { key -> - val handshakeEpoch = - statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L - handshakeMap[key] = handshakeEpoch - if (handshakeEpoch == 0L) { - if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { - _handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED) - } else { - _handshakeStatus.emit(HandshakeStatus.NOT_STARTED) - } - if (neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { - neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL / 1000).toInt() - } - return@forEach - } - // TODO one day make each peer have their own dedicated status - val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow( - handshakeEpoch - ) - if (lastHandshake != null) { - if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) { - _handshakeStatus.emit(HandshakeStatus.STALE) - } else { - _handshakeStatus.emit(HandshakeStatus.HEALTHY) - } - } - } - _lastHandshake.emit(handshakeMap) + emitBackendStatistics(statistics) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) } } } - if (state == Tunnel.State.DOWN) { + if (state == State.DOWN) { if (this::statsJob.isInitialized) { statsJob.cancel() } - _handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED) - _lastHandshake.tryEmit(emptyMap()) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt index d583e16..375611c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ActivityViewModel.kt @@ -1,8 +1,13 @@ package com.zaneschepke.wireguardautotunnel.ui import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.data.SettingsDao +import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -class ActivityViewModel @Inject constructor() : ViewModel() { - // TODO move shared logic to shared viewmodel +@HiltViewModel +class ActivityViewModel @Inject constructor( + private val settingsRepo: SettingsDao, +) : ViewModel() { + } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index 40c766a..d4a0ca2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -6,15 +6,11 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings -import android.view.KeyEvent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.focusable import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarData @@ -30,7 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost @@ -40,7 +36,6 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.wireguard.android.backend.GoBackend -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar @@ -51,10 +46,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme +import com.zaneschepke.wireguardautotunnel.util.Constants import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import timber.log.Timber @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -64,10 +59,10 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - // TODO move shared logic to shared viewmodel - // val sharedViewModel = hiltViewModel() +// val activityViewModel = hiltViewModel() + val navController = rememberNavController() - val focusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester()} WireguardAutoTunnelTheme { TransparentSystemBars() @@ -104,18 +99,13 @@ class MainActivity : AppCompatActivity() { fun showSnackBarMessage(message: String) { lifecycleScope.launch(Dispatchers.Main) { - val result = - snackbarHostState.showSnackbar( + val result = snackbarHostState.showSnackbar( message = message, actionLabel = applicationContext.getString(R.string.okay), duration = SnackbarDuration.Short ) when (result) { - SnackbarResult.ActionPerformed -> { - snackbarHostState.currentSnackbarData?.dismiss() - } - - SnackbarResult.Dismissed -> { + SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> { snackbarHostState.currentSnackbarData?.dismiss() } } @@ -134,32 +124,13 @@ class MainActivity : AppCompatActivity() { ) } }, - modifier = - Modifier.onKeyEvent { - if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { - when (it.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> { - try { - focusRequester.requestFocus() - } catch (e: IllegalStateException) { - Timber.e( - "No D-Pad focus request modifier added to element on screen" - ) - } - false - } - - else -> { - false - } - } - } else { - false - } - }, + modifier = Modifier.focusable().focusProperties { up = focusRequester }, bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { - { BottomNavBar(navController, Routes.navItems) } + { BottomNavBar(navController, listOf( + Screen.Main.navItem, + Screen.Settings.navItem, + Screen.Support.navItem)) } } else { {} } @@ -192,85 +163,31 @@ class MainActivity : AppCompatActivity() { ) return@Scaffold } - - NavHost(navController, startDestination = Routes.Main.name) { + NavHost(navController, startDestination = Screen.Main.route) { composable( - Routes.Main.name, - enterTransition = { - when (initialState.destination.route) { - Routes.Settings.name, Routes.Support.name -> - slideInHorizontally( - initialOffsetX = { - -Constants.SLIDE_IN_TRANSITION_OFFSET - }, - animationSpec = tween( - Constants.SLIDE_IN_ANIMATION_DURATION - ) - ) - - else -> { - fadeIn( - animationSpec = tween( - Constants.FADE_IN_ANIMATION_DURATION - ) - ) - } - } - }, - exitTransition = { - ExitTransition.None - } + Screen.Main.route, ) { - MainScreen(padding = padding, showSnackbarMessage = { message -> + MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) } - composable(Routes.Settings.name, enterTransition = { - when (initialState.destination.route) { - Routes.Main.name -> - slideInHorizontally( - initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET }, - animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) - ) - - Routes.Support.name -> { - slideInHorizontally( - initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET }, - animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) - ) - } - - else -> { - fadeIn( - animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION) - ) - } - } - }) { + composable(Screen.Settings.route, + ) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) } - composable(Routes.Support.name, enterTransition = { - when (initialState.destination.route) { - Routes.Settings.name, Routes.Main.name -> - slideInHorizontally( - initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION }, - animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) - ) - - else -> { - fadeIn( - animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION) - ) - } - } - }) { SupportScreen(padding = padding, focusRequester = focusRequester) } - composable("${Routes.Config.name}/{id}", enterTransition = { - fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) - }) { + composable(Screen.Support.route, + ) { + SupportScreen(padding = padding, focusRequester = focusRequester, + showSnackbarMessage = { message -> + showSnackBarMessage(message) + }) + } + composable("${Screen.Config.route}/{id}") { val id = it.arguments?.getString("id") if (!id.isNullOrBlank()) { + //https://dagger.dev/hilt/view-model#assisted-injection ConfigScreen( navController = navController, id = id, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt deleted file mode 100644 index cc4d3df..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.QuestionMark -import androidx.compose.material.icons.rounded.Settings -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem - -enum class Routes { - Main, - Settings, - Support, - Config - ; - - companion object { - val navItems = - listOf( - BottomNavItem( - name = "Tunnels", - route = Main.name, - icon = Icons.Rounded.Home - ), - BottomNavItem( - name = "Settings", - route = Settings.name, - icon = Icons.Rounded.Settings - ), - BottomNavItem( - name = "Support", - route = Support.name, - icon = Icons.Rounded.QuestionMark - ) - ) - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt new file mode 100644 index 0000000..686de5d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Screen.kt @@ -0,0 +1,33 @@ +package com.zaneschepke.wireguardautotunnel.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.QuestionMark +import androidx.compose.material.icons.rounded.Settings +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem + +sealed class Screen(val route : String) { + data object Main: Screen("main") { + val navItem = BottomNavItem( + name = "Tunnels", + route = route, + icon = Icons.Rounded.Home + ) + } + data object Settings: Screen("settings") { + val navItem = BottomNavItem( + name = "Settings", + route = route, + icon = Icons.Rounded.Settings + ) + } + data object Support: Screen("support") { + val navItem = BottomNavItem( + name = "Support", + route = route, + icon = Icons.Rounded.QuestionMark + ) + } + data object Config : Screen("config") + +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt index 023659c..3bccc4a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt @@ -1,10 +1,8 @@ package com.zaneschepke.wireguardautotunnel.ui.common import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -13,18 +11,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import com.zaneschepke.wireguardautotunnel.R @Composable fun ClickableIconButton( + onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean ) { TextButton( - onClick = {}, + onClick = onClick, enabled = enabled ) { Text(text, Modifier.weight(1f, false)) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt index 02f79d1..9e059a3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt @@ -18,8 +18,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wireguard.android.backend.Statistics -import com.zaneschepke.wireguardautotunnel.toThreeDecimalPlaceString import com.zaneschepke.wireguardautotunnel.util.NumberUtils +import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString @OptIn(ExperimentalFoundationApi::class) @Composable @@ -51,7 +51,7 @@ fun RowListItem( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 5.dp), + .padding(horizontal = 15.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt index 3e40e99..cb55319 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt @@ -38,7 +38,7 @@ fun CustomSnackBar( containerColor = containerColor, modifier = Modifier.fillMaxWidth( - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f ).padding(bottom = 100.dp), shape = RoundedCornerShape(16.dp) ) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/screen/LoadingScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/screen/LoadingScreen.kt new file mode 100644 index 0000000..0e6df0f --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/screen/LoadingScreen.kt @@ -0,0 +1,22 @@ +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() } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt index 43ccfa6..45f3bf1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -65,7 +64,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -73,24 +71,24 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.drawablepainter.DrawablePainter -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.ui.Routes +import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt +import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import timber.log.Timber +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event +import com.zaneschepke.wireguardautotunnel.util.Result +import kotlinx.coroutines.delay @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn( ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class, - ExperimentalFoundationApi::class -) + ExperimentalFoundationApi::class) @Composable fun ConfigScreen( viewModel: ConfigViewModel = hiltViewModel(), @@ -99,636 +97,454 @@ fun ConfigScreen( showSnackbarMessage: (String) -> Unit, id: String ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val clipboardManager: ClipboardManager = LocalClipboardManager.current - val keyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var showApplicationsDialog by remember { mutableStateOf(false) } + var showAuthPrompt by remember { mutableStateOf(false) } + var isAuthenticated by remember { mutableStateOf(false) } - val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) - val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle() - val packages by viewModel.packages.collectAsStateWithLifecycle() - val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle() - val include by viewModel.include.collectAsStateWithLifecycle() - val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle() - val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle() - val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle() - var showApplicationsDialog by remember { mutableStateOf(false) } - var showAuthPrompt by remember { mutableStateOf(false) } - var isAuthenticated by remember { mutableStateOf(false) } - val baseTextBoxModifier = - Modifier.onFocusChanged { - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - keyboardController?.hide() - } - } - - val keyboardActions = - KeyboardActions( - onDone = { - keyboardController?.hide() - } - ) - - val keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done - ) - - val fillMaxHeight = .85f - val fillMaxWidth = .85f - val screenPadding = 5.dp + val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - scope.launch(Dispatchers.IO) { - try { - viewModel.onScreenLoad(id) - } catch (e: Exception) { - showSnackbarMessage(e.message!!) - navController.navigate(Routes.Main.name) - } + viewModel.init(id) + } + + LaunchedEffect(uiState.loading) { + if(!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { + delay(Constants.FOCUS_REQUEST_DELAY) + focusRequester.requestFocus() + } + } + + if (uiState.loading) { + LoadingScreen() + return + } + + val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + + val keyboardOptions = + KeyboardOptions(imeAction = ImeAction.Done) + + val fillMaxHeight = .85f + val fillMaxWidth = .85f + val screenPadding = 5.dp + + val applicationButtonText = { + "Tunneling apps: " + + if (uiState.isAllApplicationsEnabled) { + "all" + } else { + "${uiState.checkedPackageNames.size} " + (if (uiState.include) "included" else "excluded") } - } + } - val applicationButtonText = { - "Tunneling apps: " + - if (isAllApplicationsEnabled) { - "all" - } else { - "${checkedPackages.size} " + (if (include) "included" else "excluded") - } - } + if (showAuthPrompt) { + AuthorizationPrompt( + onSuccess = { + showAuthPrompt = false + isAuthenticated = true + }, + onError = { error -> + showAuthPrompt = false + showSnackbarMessage(Event.Error.AuthenticationFailed.message) + }, + onFailure = { + showAuthPrompt = false + showSnackbarMessage(Event.Error.AuthorizationFailed.message) + }) + } - if (showAuthPrompt) { - AuthorizationPrompt( - onSuccess = { - showAuthPrompt = false - isAuthenticated = true - }, - onError = { error -> - showSnackbarMessage(error) - showAuthPrompt = false - }, - onFailure = { - showAuthPrompt = false - showSnackbarMessage(context.getString(R.string.authentication_failed)) - } - ) - } - - if (showApplicationsDialog) { - val sortedPackages = - remember(packages) { - packages.sortedBy { viewModel.getPackageLabel(it) } - } - AlertDialog(onDismissRequest = { - showApplicationsDialog = false - }) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f) - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(id = R.string.tunnel_all)) - Switch( - checked = isAllApplicationsEnabled, - onCheckedChange = { - viewModel.onAllApplicationsChange(it) - } - ) + if (showApplicationsDialog) { + val sortedPackages = + remember(uiState.packages) { uiState.packages.sortedBy { viewModel.getPackageLabel(it) } } + AlertDialog(onDismissRequest = { showApplicationsDialog = false }) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier.fillMaxWidth() + .fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(id = R.string.tunnel_all)) + Switch( + checked = uiState.isAllApplicationsEnabled, + onCheckedChange = { viewModel.onAllApplicationsChange(it) }) + } + if (!uiState.isAllApplicationsEnabled) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(id = R.string.include)) + Checkbox( + checked = uiState.include, + onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text(stringResource(id = R.string.exclude)) + Checkbox( + checked = !uiState.include, + onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) + } } - if (!isAllApplicationsEnabled) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + SearchBar(viewModel::emitQueriedPackages) + } + Spacer(Modifier.padding(5.dp)) + LazyColumn( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxHeight(4 / 5f)) { + items(sortedPackages, key = { it.packageName }) { pack -> Row( - modifier = - Modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 7.dp - ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(id = R.string.include)) - Checkbox( - checked = include, - onCheckedChange = { - viewModel.onIncludeChange(!include) + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize().padding(5.dp)) { + Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) { + val drawable = + pack.applicationInfo?.loadIcon(context.packageManager) + if (drawable != null) { + Image( + painter = DrawablePainter(drawable), + stringResource(id = R.string.icon), + modifier = Modifier.size(50.dp, 50.dp)) + } else { + Icon( + Icons.Rounded.Android, + stringResource(id = R.string.edit), + modifier = Modifier.size(50.dp, 50.dp)) + } + Text( + viewModel.getPackageLabel(pack), + modifier = Modifier.padding(5.dp)) + } + Checkbox( + modifier = Modifier.fillMaxSize(), + checked = + (uiState.checkedPackageNames.contains(pack.packageName)), + onCheckedChange = { + if (it) { + viewModel.onAddCheckedPackage(pack.packageName) + } else { + viewModel.onRemoveCheckedPackage(pack.packageName) } - ) + }) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(id = R.string.exclude)) - Checkbox( - checked = !include, - onCheckedChange = { - viewModel.onIncludeChange(!include) - } - ) - } - } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 7.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - SearchBar(viewModel::emitQueriedPackages) - } - Spacer(Modifier.padding(5.dp)) - LazyColumn( + } + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(top = 5.dp), + horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { showApplicationsDialog = false }) { + Text(stringResource(R.string.done)) + } + } + } + } + } + } + + Scaffold( + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + val secondaryColor = MaterialTheme.colorScheme.secondary + val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + var fobColor by remember { mutableStateOf(secondaryColor) } + FloatingActionButton( + modifier = + Modifier.padding(bottom = 90.dp).onFocusChanged { + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + fobColor = if (it.isFocused) hoverColor else secondaryColor + } + }, + onClick = { + viewModel.onSaveAllChanges().let { + when (it) { + is Result.Success -> { + showSnackbarMessage(it.data.message) + navController.navigate(Screen.Main.route) + } + is Result.Error -> showSnackbarMessage(it.error.message) + } + } + }, + containerColor = fobColor, + shape = RoundedCornerShape(16.dp)) { + Icon( + imageVector = Icons.Rounded.Save, + contentDescription = stringResource(id = R.string.save_changes), + tint = Color.DarkGray) + } + }) { + Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = + Modifier.verticalScroll(rememberScrollState()).weight(1f, true).fillMaxSize()) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) + } else { + Modifier.fillMaxWidth(fillMaxWidth) + }) + .padding(top = 50.dp, bottom = 10.dp)) { + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, - modifier = - Modifier - .fillMaxHeight(4 / 5f) - ) { - items( - sortedPackages, - key = { it.packageName } - ) { pack -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(15.dp).focusGroup()) { + SectionTitle( + stringResource(R.string.interface_), padding = screenPadding) + ConfigurationTextBox( + value = uiState.tunnelName, + onValueChange = { value -> viewModel.onTunnelNameChange(value) }, + keyboardActions = keyboardActions, + label = stringResource(R.string.name), + hint = stringResource(R.string.tunnel_name).lowercase(), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester)) + OutlinedTextField( + modifier = + Modifier.fillMaxWidth().clickable { + showAuthPrompt = true + }, + value = uiState.interfaceProxy.privateKey, + visualTransformation = + if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || + isAuthenticated) + VisualTransformation.None + else PasswordVisualTransformation(), + enabled = + (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, + onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, + trailingIcon = { + IconButton( + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { viewModel.generateKeyPair() }) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = Color.White) + } + }, + label = { Text(stringResource(R.string.private_key)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.base64_key)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions) + OutlinedTextField( + modifier = + Modifier + .fillMaxWidth() + .focusRequester(FocusRequester.Default), + value = uiState.interfaceProxy.publicKey, + enabled = false, + onValueChange = {}, + trailingIcon = { + IconButton( + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { + clipboardManager.setText( + AnnotatedString(uiState.interfaceProxy.publicKey)) + }) { + Icon( + Icons.Rounded.ContentCopy, + stringResource(R.string.copy_public_key), + tint = Color.White) + } + }, + label = { Text(stringResource(R.string.public_key)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.base64_key)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions) + Row(modifier = Modifier.fillMaxWidth()) { + ConfigurationTextBox( + value = uiState.interfaceProxy.addresses, + onValueChange = { value -> + viewModel.onAddressesChanged(value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.addresses), + hint = stringResource(R.string.comma_separated_list), modifier = - Modifier - .fillMaxSize() - .padding(5.dp) - ) { - Row( - modifier = - Modifier.fillMaxWidth( - fillMaxWidth - ) - ) { - val drawable = - pack.applicationInfo?.loadIcon( - context.packageManager - ) - if (drawable != null) { - Image( - painter = - DrawablePainter( - drawable - ), - stringResource(id = R.string.icon), - modifier = - Modifier.size( - 50.dp, - 50.dp - ) - ) - } else { - Icon( - Icons.Rounded.Android, - stringResource(id = R.string.edit), - modifier = - Modifier.size( - 50.dp, - 50.dp - ) - ) - } - Text( - viewModel.getPackageLabel(pack), - modifier = Modifier.padding(5.dp) - ) + Modifier + .fillMaxWidth(3 / 5f) + .padding(end = 5.dp)) + ConfigurationTextBox( + value = uiState.interfaceProxy.listenPort, + onValueChange = { value -> + viewModel.onListenPortChanged(value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.listen_port), + hint = stringResource(R.string.random), + modifier = Modifier.width(IntrinsicSize.Min)) + } + Row(modifier = Modifier.fillMaxWidth()) { + ConfigurationTextBox( + value = uiState.interfaceProxy.dnsServers, + onValueChange = { value -> + viewModel.onDnsServersChanged(value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.dns_servers), + hint = stringResource(R.string.comma_separated_list), + modifier = + Modifier + .fillMaxWidth(3 / 5f) + .padding(end = 5.dp)) + ConfigurationTextBox( + value = uiState.interfaceProxy.mtu, + onValueChange = { value -> viewModel.onMtuChanged(value) }, + keyboardActions = keyboardActions, + label = stringResource(R.string.mtu), + hint = stringResource(R.string.auto), + modifier = Modifier.width(IntrinsicSize.Min)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(top = 5.dp), + horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { showApplicationsDialog = true }) { + Text(applicationButtonText()) } - Checkbox( - modifier = Modifier.fillMaxSize(), - checked = (checkedPackages.contains(pack.packageName)), - onCheckedChange = { - if (it) { - viewModel.onAddCheckedPackage( - pack.packageName - ) - } else { - viewModel.onRemoveCheckedPackage( - pack.packageName - ) - } - } - ) - } + } } - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { - showApplicationsDialog = false - } - ) { - Text(stringResource(R.string.done)) - } - } - } - } - } - } - - if (tunnel != null) { - Scaffold( - floatingActionButtonPosition = FabPosition.End, - floatingActionButton = { - val secondaryColor = MaterialTheme.colorScheme.secondary - val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) - var fobColor by remember { mutableStateOf(secondaryColor) } - FloatingActionButton( - modifier = - Modifier.padding(bottom = 90.dp).onFocusChanged { - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - fobColor = if (it.isFocused) hoverColor else secondaryColor - } - }, - onClick = { - scope.launch { - try { - viewModel.onSaveAllChanges() - navController.navigate(Routes.Main.name) - showSnackbarMessage( - context.resources.getString(R.string.config_changes_saved) - ) - } catch (e: Exception) { - Timber.e(e.message) - showSnackbarMessage(e.message!!) - } - } - }, - containerColor = fobColor, - shape = RoundedCornerShape(16.dp) - ) { - Icon( - imageVector = Icons.Rounded.Save, - contentDescription = stringResource(id = R.string.save_changes), - tint = Color.DarkGray - ) - } - } - ) { - Column { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .verticalScroll(rememberScrollState()) - .weight(1f, true) - .fillMaxSize() - ) { + } + uiState.proxyPeers.forEachIndexed { index, peer -> Surface( tonalElevation = 2.dp, shadowElevation = 2.dp, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, modifier = - ( - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Modifier - .fillMaxHeight(fillMaxHeight) - .fillMaxWidth(fillMaxWidth) + (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) } else { - Modifier.fillMaxWidth(fillMaxWidth) - } - ).padding( - top = 50.dp, - bottom = 10.dp - ) - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp).focusGroup() - ) { - SectionTitle( - stringResource(R.string.interface_), - padding = screenPadding - ) - ConfigurationTextBox( - value = tunnelName.value, - onValueChange = { value -> - viewModel.onTunnelNameChange(value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.name), - hint = stringResource(R.string.tunnel_name).lowercase(), - modifier = baseTextBoxModifier.fillMaxWidth().focusRequester( - focusRequester - ) - ) - OutlinedTextField( - modifier = - baseTextBoxModifier.fillMaxWidth().clickable { - showAuthPrompt = true - }, - value = proxyInterface.privateKey, - visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(), - enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, - onValueChange = { value -> - viewModel.onPrivateKeyChange(value) - }, - trailingIcon = { - IconButton( - modifier = Modifier.focusRequester(FocusRequester.Default), - onClick = { - viewModel.generateKeyPair() - } - ) { - Icon( - Icons.Rounded.Refresh, - stringResource(R.string.rotate_keys), - tint = Color.White - ) - } - }, - label = { Text(stringResource(R.string.private_key)) }, - singleLine = true, - placeholder = { Text(stringResource(R.string.base64_key)) }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) - OutlinedTextField( - modifier = baseTextBoxModifier.fillMaxWidth().focusRequester( - FocusRequester.Default - ), - value = proxyInterface.publicKey, - enabled = false, - onValueChange = {}, - trailingIcon = { - IconButton( - modifier = Modifier.focusRequester(FocusRequester.Default), - onClick = { - clipboardManager.setText( - AnnotatedString(proxyInterface.publicKey) - ) - } - ) { - Icon( - Icons.Rounded.ContentCopy, - stringResource(R.string.copy_public_key), - tint = Color.White - ) - } - }, - label = { Text(stringResource(R.string.public_key)) }, - singleLine = true, - placeholder = { Text(stringResource(R.string.base64_key)) }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) - Row(modifier = Modifier.fillMaxWidth()) { - ConfigurationTextBox( - value = proxyInterface.addresses, - onValueChange = { value -> - viewModel.onAddressesChanged(value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.addresses), - hint = stringResource(R.string.comma_separated_list), - modifier = - baseTextBoxModifier - .fillMaxWidth(3 / 5f) - .padding(end = 5.dp) - ) - ConfigurationTextBox( - value = proxyInterface.listenPort, - onValueChange = { value -> viewModel.onListenPortChanged(value) }, - keyboardActions = keyboardActions, - label = stringResource(R.string.listen_port), - hint = stringResource(R.string.random), - modifier = baseTextBoxModifier.width(IntrinsicSize.Min) - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - ConfigurationTextBox( - value = proxyInterface.dnsServers, - onValueChange = { value -> viewModel.onDnsServersChanged(value) }, - keyboardActions = keyboardActions, - label = stringResource(R.string.dns_servers), - hint = stringResource(R.string.comma_separated_list), - modifier = - baseTextBoxModifier - .fillMaxWidth(3 / 5f) - .padding(end = 5.dp) - ) - ConfigurationTextBox( - value = proxyInterface.mtu, - onValueChange = { value -> viewModel.onMtuChanged(value) }, - keyboardActions = keyboardActions, - label = stringResource(R.string.mtu), - hint = stringResource(R.string.auto), - modifier = baseTextBoxModifier.width(IntrinsicSize.Min) - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { - showApplicationsDialog = true - } - ) { - Text(applicationButtonText()) - } - } - } - } - proxyPeers.forEachIndexed { index, peer -> - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - ( - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Modifier - .fillMaxHeight(fillMaxHeight) - .fillMaxWidth(fillMaxWidth) - } else { - Modifier.fillMaxWidth(fillMaxWidth) - } - ).padding( - top = 10.dp, - bottom = 10.dp - ) - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .padding(horizontal = 15.dp) - .padding(bottom = 10.dp) - ) { + Modifier.fillMaxWidth(fillMaxWidth) + }) + .padding(top = 10.dp, bottom = 10.dp)) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = + Modifier.padding(horizontal = 15.dp).padding(bottom = 10.dp)) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 5.dp) - ) { - SectionTitle( - stringResource(R.string.peer), - padding = screenPadding - ) - IconButton( - onClick = { - viewModel.onDeletePeer(index) - } - ) { + modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp)) { + SectionTitle( + stringResource(R.string.peer), padding = screenPadding) + IconButton(onClick = { viewModel.onDeletePeer(index) }) { Icon(Icons.Rounded.Delete, stringResource(R.string.delete)) + } } - } ConfigurationTextBox( value = peer.publicKey, onValueChange = { value -> - viewModel.onPeerPublicKeyChange( - index, - value - ) + viewModel.onPeerPublicKeyChange(index, value) }, keyboardActions = keyboardActions, label = stringResource(R.string.public_key), hint = stringResource(R.string.base64_key), - modifier = baseTextBoxModifier.fillMaxWidth() - ) + modifier = Modifier.fillMaxWidth()) ConfigurationTextBox( value = peer.preSharedKey, onValueChange = { value -> - viewModel.onPreSharedKeyChange( - index, - value - ) + viewModel.onPreSharedKeyChange(index, value) }, keyboardActions = keyboardActions, label = stringResource(R.string.preshared_key), hint = stringResource(R.string.optional), - modifier = baseTextBoxModifier.fillMaxWidth() - ) + modifier = Modifier.fillMaxWidth()) OutlinedTextField( - modifier = baseTextBoxModifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), value = peer.persistentKeepalive, enabled = true, onValueChange = { value -> - viewModel.onPersistentKeepaliveChanged(index, value) + viewModel.onPersistentKeepaliveChanged(index, value) }, trailingIcon = { - Text( - stringResource(R.string.seconds), - modifier = Modifier.padding(end = 10.dp) - ) + Text( + stringResource(R.string.seconds), + modifier = Modifier.padding(end = 10.dp)) }, label = { Text(stringResource(R.string.persistent_keepalive)) }, singleLine = true, placeholder = { - Text(stringResource(R.string.optional_no_recommend)) + Text(stringResource(R.string.optional_no_recommend)) }, keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) + keyboardActions = keyboardActions) ConfigurationTextBox( value = peer.endpoint, onValueChange = { value -> - viewModel.onEndpointChange( - index, - value - ) + viewModel.onEndpointChange(index, value) }, keyboardActions = keyboardActions, label = stringResource(R.string.endpoint), hint = stringResource(R.string.endpoint).lowercase(), - modifier = baseTextBoxModifier.fillMaxWidth() - ) + modifier = Modifier.fillMaxWidth()) OutlinedTextField( - modifier = baseTextBoxModifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), value = peer.allowedIps, enabled = true, onValueChange = { value -> - viewModel.onAllowedIpsChange( - index, - value - ) + viewModel.onAllowedIpsChange(index, value) }, label = { Text(stringResource(R.string.allowed_ips)) }, singleLine = true, placeholder = { - Text(stringResource(R.string.comma_separated_list)) + Text(stringResource(R.string.comma_separated_list)) }, keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) - } + keyboardActions = keyboardActions) + } } - } - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(bottom = 140.dp) - ) { + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize().padding(bottom = 140.dp)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - TextButton( - onClick = { - viewModel.addEmptyPeer() - } - ) { + horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { viewModel.addEmptyPeer() }) { Text(stringResource(R.string.add_peer)) + } } - } - } - } - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Spacer(modifier = Modifier.weight(.17f)) + } } + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Spacer(modifier = Modifier.weight(.17f)) } + } } - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt new file mode 100644 index 0000000..dbeba0a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt @@ -0,0 +1,18 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.config + +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy +import com.zaneschepke.wireguardautotunnel.util.Packages + +data class ConfigUiState( + val proxyPeers: List = arrayListOf(PeerProxy()), + val interfaceProxy: InterfaceProxy = InterfaceProxy(), + val packages: Packages = emptyList(), + val checkedPackageNames: List = emptyList(), + val include: Boolean = true, + val isAllApplicationsEnabled : Boolean = false, + val loading: Boolean = true, + val tunnel: TunnelConfig? = null, + val tunnelName: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt index 95481e1..82a2990 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -5,8 +5,6 @@ import android.app.Application import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.toMutableStateList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.config.Config @@ -14,426 +12,301 @@ import com.wireguard.config.Interface import com.wireguard.config.Peer import com.wireguard.crypto.Key import com.wireguard.crypto.KeyPair -import com.zaneschepke.wireguardautotunnel.Constants -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException +import com.zaneschepke.wireguardautotunnel.util.Result +import com.zaneschepke.wireguardautotunnel.util.removeAt +import com.zaneschepke.wireguardautotunnel.util.update import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class ConfigViewModel @Inject constructor( private val application: Application, - private val tunnelRepo: TunnelConfigDao, - private val settingsRepo: SettingsDoa + private val tunnelConfigRepository: TunnelConfigRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { - private val _tunnel = MutableStateFlow(null) - private val _tunnelName = MutableStateFlow("") - val tunnelName get() = _tunnelName.asStateFlow() - val tunnel get() = _tunnel.asStateFlow() - private var _proxyPeers = MutableStateFlow(mutableStateListOf()) - val proxyPeers get() = _proxyPeers.asStateFlow() + private val packageManager = application.packageManager - private var _interface = MutableStateFlow(InterfaceProxy()) - val interfaceProxy = _interface.asStateFlow() + private val _uiState = MutableStateFlow(ConfigUiState()) + val uiState = _uiState.asStateFlow() - private val _packages = MutableStateFlow(emptyList()) - val packages get() = _packages.asStateFlow() - private val packageManager = application.packageManager - - private val _checkedPackages = MutableStateFlow(mutableStateListOf()) - val checkedPackages get() = _checkedPackages.asStateFlow() - private val _include = MutableStateFlow(true) - val include get() = _include.asStateFlow() - - private val _isAllApplicationsEnabled = MutableStateFlow(false) - val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow() - private val _isDefaultTunnel = MutableStateFlow(false) - - private lateinit var tunnelConfig: TunnelConfig - - suspend fun onScreenLoad(id: String) { - if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) { - tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException( - "Config not found" - ) - emitScreenData() - } else { - emitEmptyScreenData() - } - } - - private fun emitEmptyScreenData() { - tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "") - viewModelScope.launch { - emitTunnelConfig() - emitPeerProxy(PeerProxy()) - emitInterfaceProxy(InterfaceProxy()) - emitTunnelConfigName() - emitDefaultTunnelStatus() - emitQueriedPackages("") - emitTunnelAllApplicationsEnabled() - } - } - - private suspend fun emitScreenData() { - emitTunnelConfig() - emitPeersFromConfig() - emitInterfaceFromConfig() - emitTunnelConfigName() - emitDefaultTunnelStatus() - emitQueriedPackages("") - emitCurrentPackageConfigurations() - } - - private suspend fun emitDefaultTunnelStatus() { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - _isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig) - } - } - - private fun emitInterfaceFromConfig() { - val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) - _interface.value = InterfaceProxy.from(config.`interface`) - } - - private fun emitPeersFromConfig() { - val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) - config.peers.forEach { - _proxyPeers.value.add(PeerProxy.from(it)) - } - } - - private fun emitPeerProxy(peerProxy: PeerProxy) { - _proxyPeers.value.add(peerProxy) - } - - private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) { - _interface.value = interfaceProxy - } - - private suspend fun getTunnelConfigById(id: String): TunnelConfig? { - return try { - tunnelRepo.getById(id.toLong()) - } catch (_: Exception) { - null - } - } - - private suspend fun emitTunnelConfig() { - _tunnel.emit(tunnelConfig) - } - - private suspend fun emitTunnelConfigName() { - _tunnelName.emit(tunnelConfig.name) - } - - fun onTunnelNameChange(name: String) { - _tunnelName.value = name - } - - fun onIncludeChange(include: Boolean) { - _include.value = include - } - - fun onAddCheckedPackage(packageName: String) { - _checkedPackages.value.add(packageName) - } - - fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { - _isAllApplicationsEnabled.value = isAllApplicationsEnabled - } - - fun onRemoveCheckedPackage(packageName: String) { - _checkedPackages.value.remove(packageName) - } - - private suspend fun emitSplitTunnelConfiguration(config: Config) { - val excludedApps = config.`interface`.excludedApplications - val includedApps = config.`interface`.includedApplications - if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) { - emitTunnelAllApplicationsDisabled() - determineAppInclusionState(excludedApps, includedApps) - } else { - emitTunnelAllApplicationsEnabled() - } - } - - private suspend fun determineAppInclusionState( - excludedApps: Set, - includedApps: Set - ) { - if (excludedApps.isEmpty()) { - emitIncludedAppsExist() - emitCheckedApps(includedApps) - } else { - emitExcludedAppsExist() - emitCheckedApps(excludedApps) - } - } - - private suspend fun emitIncludedAppsExist() { - _include.emit(true) - } - - private suspend fun emitExcludedAppsExist() { - _include.emit(false) - } - - private suspend fun emitCheckedApps(apps: Set) { - _checkedPackages.emit(apps.toMutableStateList()) - } - - private suspend fun emitTunnelAllApplicationsEnabled() { - _isAllApplicationsEnabled.emit(true) - } - - private suspend fun emitTunnelAllApplicationsDisabled() { - _isAllApplicationsEnabled.emit(false) - } - - private fun emitCurrentPackageConfigurations() { - viewModelScope.launch(Dispatchers.IO) { - val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) - emitSplitTunnelConfiguration(config) - } - } - - fun emitQueriedPackages(query: String) { - viewModelScope.launch(Dispatchers.IO) { - val packages = - getAllInternetCapablePackages().filter { - getPackageLabel(it).lowercase().contains(query.lowercase()) - } - _packages.emit(packages) - } - } - - fun getPackageLabel(packageInfo: PackageInfo): String { - return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() - } - - private fun getAllInternetCapablePackages(): List { - return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) - } - - private fun getPackagesHoldingPermissions(permissions: Array): List { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackagesHoldingPermissions( - permissions, - PackageManager.PackageInfoFlags.of(0L) - ) - } else { - packageManager.getPackagesHoldingPermissions(permissions, 0) - } - } - - private fun isAllApplicationsEnabled(): Boolean { - return _isAllApplicationsEnabled.value - } - - private suspend fun saveConfig(tunnelConfig: TunnelConfig) { - tunnelRepo.save(tunnelConfig) - } - - private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { - if (tunnelConfig != null) { - saveConfig(tunnelConfig) - updateSettingsDefaultTunnel(tunnelConfig) - } - } - - private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings[0] - if (setting.defaultTunnel != null) { - if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) { - settingsRepo.save( - setting.copy( - defaultTunnel = tunnelConfig.toString() - ) - ) - } - } - } - } - - private fun buildPeerListFromProxyPeers(): List { - return _proxyPeers.value.map { - val builder = Peer.Builder() - if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) - if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) - if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) - if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) - if (it.persistentKeepalive.isNotEmpty()) { - builder.parsePersistentKeepalive( - it.persistentKeepalive.trim() - ) - } - builder.build() - } - } - - private fun buildInterfaceListFromProxyInterface(): Interface { - val builder = Interface.Builder() - builder.parsePrivateKey(_interface.value.privateKey.trim()) - builder.parseAddresses(_interface.value.addresses.trim()) - builder.parseDnsServers(_interface.value.dnsServers.trim()) - if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim()) - if (_interface.value.listenPort.isNotEmpty()) { - builder.parseListenPort( - _interface.value.listenPort.trim() - ) - } - if (isAllApplicationsEnabled()) _checkedPackages.value.clear() - if (_include.value) builder.includeApplications(_checkedPackages.value) - if (!_include.value) builder.excludeApplications(_checkedPackages.value) - return builder.build() - } - - suspend fun onSaveAllChanges() { - try { - val peerList = buildPeerListFromProxyPeers() - val wgInterface = buildInterfaceListFromProxyInterface() - val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() + fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) { + val packages = getQueriedPackages("") + val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) { val tunnelConfig = - _tunnel.value?.copy( - name = _tunnelName.value, - wgQuick = config.toWgQuickString() - ) - updateTunnelConfig(tunnelConfig) - } catch (e: Exception) { - throw WgTunnelException( - "Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}" - ) - } - } - - fun onPeerPublicKeyChange( - index: Int, - publicKey: String - ) { - _proxyPeers.value[index] = - _proxyPeers.value[index].copy( - publicKey = publicKey - ) - } - - fun onPreSharedKeyChange( - index: Int, - value: String - ) { - _proxyPeers.value[index] = - _proxyPeers.value[index].copy( - preSharedKey = value - ) - } - - fun onEndpointChange( - index: Int, - value: String - ) { - _proxyPeers.value[index] = - _proxyPeers.value[index].copy( - endpoint = value - ) - } - - fun onAllowedIpsChange( - index: Int, - value: String - ) { - _proxyPeers.value[index] = - _proxyPeers.value[index].copy( - allowedIps = value - ) - } - - fun onPersistentKeepaliveChanged( - index: Int, - value: String - ) { - _proxyPeers.value[index] = - _proxyPeers.value[index].copy( - persistentKeepalive = value - ) - } - - fun onDeletePeer(index: Int) { - proxyPeers.value.removeAt(index) - } - - fun addEmptyPeer() { - _proxyPeers.value.add(PeerProxy()) - } - - fun generateKeyPair() { - val keyPair = KeyPair() - _interface.value = - _interface.value.copy( - privateKey = keyPair.privateKey.toBase64(), - publicKey = keyPair.publicKey.toBase64() - ) - } - - fun onAddressesChanged(value: String) { - _interface.value = - _interface.value.copy( - addresses = value - ) - } - - fun onListenPortChanged(value: String) { - _interface.value = - _interface.value.copy( - listenPort = value - ) - } - - fun onDnsServersChanged(value: String) { - _interface.value = - _interface.value.copy( - dnsServers = value - ) - } - - fun onMtuChanged(value: String) { - _interface.value = - _interface.value.copy( - mtu = value - ) - } - - private fun onInterfacePublicKeyChange(value: String) { - _interface.value = - _interface.value.copy( - publicKey = value - ) - } - - fun onPrivateKeyChange(value: String) { - _interface.value = - _interface.value.copy( - privateKey = value - ) - if (NumberUtils.isValidKey(value)) { - val pair = KeyPair(Key.fromBase64(value)) - onInterfacePublicKeyChange(pair.publicKey.toBase64()) + tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId } + if (tunnelConfig != null) { + val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) + val proxyPeers = config.peers.map { PeerProxy.from(it) } + val proxyInterface = InterfaceProxy.from(config.`interface`) + var include = true + var isAllApplicationsEnabled = false + val checkedPackages = + if (config.`interface`.includedApplications.isNotEmpty()) { + config.`interface`.includedApplications + } else if (config.`interface`.excludedApplications.isNotEmpty()) { + include = false + config.`interface`.excludedApplications + } else { + isAllApplicationsEnabled = true + emptySet() + } + ConfigUiState( + proxyPeers, + proxyInterface, + packages, + checkedPackages.toList(), + include, + isAllApplicationsEnabled, + false, + tunnelConfig, + tunnelConfig.name) + } else { + ConfigUiState(loading = false, packages = packages) + } } else { - onInterfacePublicKeyChange("") + ConfigUiState(loading = false, packages = packages) } + _uiState.value = state } + fun onTunnelNameChange(name: String) { + _uiState.value = _uiState.value.copy(tunnelName = name) + } + + fun onIncludeChange(include: Boolean) { + _uiState.value = _uiState.value.copy(include = include) + } + + fun onAddCheckedPackage(packageName: String) { + _uiState.value = + _uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames + packageName) + } + + fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { + _uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled) + } + + fun onRemoveCheckedPackage(packageName: String) { + _uiState.value = + _uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames - packageName) + } + + private fun getQueriedPackages(query: String): List { + return getAllInternetCapablePackages().filter { + getPackageLabel(it).lowercase().contains(query.lowercase()) + } + } + + fun getPackageLabel(packageInfo: PackageInfo): String { + return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() + } + + private fun getAllInternetCapablePackages(): List { + return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) + } + + private fun getPackagesHoldingPermissions(permissions: Array): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackagesHoldingPermissions( + permissions, PackageManager.PackageInfoFlags.of(0L)) + } else { + packageManager.getPackagesHoldingPermissions(permissions, 0) + } + } + + private fun isAllApplicationsEnabled(): Boolean { + return _uiState.value.isAllApplicationsEnabled + } + + private fun saveConfig(tunnelConfig: TunnelConfig) = + viewModelScope.launch { + tunnelConfigRepository.save(tunnelConfig) + } + + private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = + viewModelScope.launch { + if (tunnelConfig != null) { + saveConfig(tunnelConfig).join() + WireGuardAutoTunnel.requestTileServiceStateUpdate() + updateSettingsDefaultTunnel(tunnelConfig) + } + } + + private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { + val settings = settingsRepository.getSettingsFlow().first() + if (settings.defaultTunnel != null) { + if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) { + settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString())) + } + } + } + + private fun buildPeerListFromProxyPeers(): List { + return _uiState.value.proxyPeers.map { + val builder = Peer.Builder() + if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) + if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) + if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) + if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) + if (it.persistentKeepalive.isNotEmpty()) { + builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) + } + builder.build() + } + } + + private fun emptyCheckedPackagesList() { + _uiState.value = _uiState.value.copy(checkedPackageNames = emptyList()) + } + + private fun buildInterfaceListFromProxyInterface(): Interface { + val builder = Interface.Builder() + builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim()) + builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim()) + builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim()) + if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) + builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim()) + if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) { + builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim()) + } + if (isAllApplicationsEnabled()) emptyCheckedPackagesList() + if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames) + if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames) + return builder.build() + } + + fun onSaveAllChanges(): Result { + return try { + val peerList = buildPeerListFromProxyPeers() + val wgInterface = buildInterfaceListFromProxyInterface() + val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() + val tunnelConfig = + _uiState.value.tunnel?.copy( + name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString()) + updateTunnelConfig(tunnelConfig) + Result.Success(Event.Message.ConfigSaved) + } catch (e: Exception) { + Result.Error(Event.Error.Exception(e)) + } + } + + fun onPeerPublicKeyChange(index: Int, value: String) { + _uiState.value = + _uiState.value.copy( + proxyPeers = + _uiState.value.proxyPeers.update( + index, _uiState.value.proxyPeers[index].copy(publicKey = value))) + } + + fun onPreSharedKeyChange(index: Int, value: String) { + _uiState.value = + _uiState.value.copy( + proxyPeers = + _uiState.value.proxyPeers.update( + index, _uiState.value.proxyPeers[index].copy(preSharedKey = value))) + } + + fun onEndpointChange(index: Int, value: String) { + _uiState.value = + _uiState.value.copy( + proxyPeers = + _uiState.value.proxyPeers.update( + index, _uiState.value.proxyPeers[index].copy(endpoint = value))) + } + + fun onAllowedIpsChange(index: Int, value: String) { + _uiState.value = + _uiState.value.copy( + proxyPeers = + _uiState.value.proxyPeers.update( + index, _uiState.value.proxyPeers[index].copy(allowedIps = value))) + } + + fun onPersistentKeepaliveChanged(index: Int, value: String) { + _uiState.value = + _uiState.value.copy( + proxyPeers = + _uiState.value.proxyPeers.update( + index, _uiState.value.proxyPeers[index].copy(persistentKeepalive = value))) + } + + fun onDeletePeer(index: Int) { + _uiState.value = _uiState.value.copy( + proxyPeers = _uiState.value.proxyPeers.removeAt(index) + ) + } + + fun addEmptyPeer() { + _uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy()) + } + + fun generateKeyPair() { + val keyPair = KeyPair() + _uiState.value = + _uiState.value.copy( + interfaceProxy = + _uiState.value.interfaceProxy.copy( + privateKey = keyPair.privateKey.toBase64(), + publicKey = keyPair.publicKey.toBase64())) + } + + fun onAddressesChanged(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value)) + } + + fun onListenPortChanged(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value)) + } + + fun onDnsServersChanged(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value)) + } + + fun onMtuChanged(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value)) + } + + private fun onInterfacePublicKeyChange(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value)) + } + + fun onPrivateKeyChange(value: String) { + _uiState.value = + _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value)) + if (NumberUtils.isValidKey(value)) { + val pair = KeyPair(Key.fromBase64(value)) + onInterfacePublicKeyChange(pair.publicKey.toBase64()) + } else { + onInterfacePublicKeyChange("") + } + } + + fun emitQueriedPackages(query: String) { + val packages = + getAllInternetCapablePackages().filter { + getPackageLabel(it).lowercase().contains(query.lowercase()) + } + _uiState.value = _uiState.value.copy(packages = packages) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index 3649c5c..dfc2ab7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -8,27 +8,36 @@ import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.overscroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit @@ -42,14 +51,18 @@ import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,13 +74,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource @@ -79,526 +89,474 @@ import androidx.navigation.NavController import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.wireguard.android.backend.Tunnel -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait -import com.zaneschepke.wireguardautotunnel.ui.Routes +import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem -import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed +import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen import com.zaneschepke.wireguardautotunnel.ui.theme.corn import com.zaneschepke.wireguardautotunnel.ui.theme.mint -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event +import com.zaneschepke.wireguardautotunnel.util.Result +import com.zaneschepke.wireguardautotunnel.util.handshakeStatus +import com.zaneschepke.wireguardautotunnel.util.mapPeerStats import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MainScreen( viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues, + focusRequester: FocusRequester, showSnackbarMessage: (String) -> Unit, navController: NavController ) { - val haptic = LocalHapticFeedback.current - val context = LocalContext.current - val isVisible = rememberSaveable { mutableStateOf(true) } - val scope = rememberCoroutineScope { Dispatchers.IO } + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val isVisible = rememberSaveable { mutableStateOf(true) } + val scope = rememberCoroutineScope { Dispatchers.IO } - val sheetState = rememberModalBottomSheetState() - var showBottomSheet by remember { mutableStateOf(false) } - var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } - val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) - val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle( - HandshakeStatus.NOT_STARTED - ) - var selectedTunnel by remember { mutableStateOf(null) } - val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) - val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") - val settings by viewModel.settings.collectAsStateWithLifecycle() - val statistics by viewModel.statistics.collectAsStateWithLifecycle(null) + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } - // Nested scroll for control FAB - val nestedScrollConnection = - remember { - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - // Hide FAB - if (available.y < -1) { - isVisible.value = false - } - // Show FAB - if (available.y > 1) { - isVisible.value = true - } - return Offset.Zero - } + var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } + var selectedTunnel by remember { mutableStateOf(null) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.loading) { + if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { + delay(Constants.FOCUS_REQUEST_DELAY) + focusRequester.requestFocus() + } + } + + if (uiState.loading) { + LoadingScreen() + return + } + + val tunnelFileImportResultLauncher = + rememberLauncherForActivityResult( + object : ActivityResultContracts.GetContent() { + override fun createIntent(context: Context, input: String): Intent { + val intent = super.createIntent(context, input) + + /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than + * what we can do, so detect this and throw an exception that we can catch later. */ + val activitiesToResolveIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of( + PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + context.packageManager.queryIntentActivities( + intent, PackageManager.MATCH_DEFAULT_ONLY) + } + if (activitiesToResolveIntent.all { + val name = it.activityInfo.packageName + name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || + name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) + }) { + showSnackbarMessage(Event.Error.FileExplorerRequired.message) + } + return intent } - } - - val tunnelFileImportResultLauncher = - rememberLauncherForActivityResult( - object : ActivityResultContracts.GetContent() { - override fun createIntent( - context: Context, - input: String - ): Intent { - val intent = super.createIntent(context, input) - - /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than - * what we can do, so detect this and throw an exception that we can catch later. */ - val activitiesToResolveIntent = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.packageManager.queryIntentActivities( - intent, - PackageManager.ResolveInfoFlags.of( - PackageManager.MATCH_DEFAULT_ONLY.toLong() - ) - ) - } else { - context.packageManager.queryIntentActivities( - intent, - PackageManager.MATCH_DEFAULT_ONLY - ) - } - if (activitiesToResolveIntent.all { - val name = it.activityInfo.packageName - name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith( - Constants.ANDROID_TV_EXPLORER_STUB - ) - } - ) { - throw WgTunnelException(context.getString(R.string.no_file_explorer)) - } - return intent - } - } - ) { data -> + }) { data -> if (data == null) return@rememberLauncherForActivityResult - scope.launch(Dispatchers.IO) { - try { - viewModel.onTunnelFileSelected(data) - } catch (e: WgTunnelException) { - showSnackbarMessage(e.message) + scope.launch { + viewModel.onTunnelFileSelected(data).let { + when (it) { + is Result.Error -> showSnackbarMessage(it.error.message) + is Result.Success -> {} } + } } - } - - val scanLauncher = - rememberLauncherForActivityResult( - contract = ScanContract(), - onResult = { - scope.launch { - try { - viewModel.onTunnelQrResult(it.contents) - } catch (e: Exception) { - when (e) { - is WgTunnelException -> { - showSnackbarMessage(e.message) - } - - else -> { - showSnackbarMessage("No QR code scanned") - } - } - } + } + val scanLauncher = + rememberLauncherForActivityResult( + contract = ScanContract(), + onResult = { + if (it.contents != null) { + scope.launch { + viewModel.onTunnelQrResult(it.contents).let { result -> + when (result) { + is Result.Success -> {} + is Result.Error -> showSnackbarMessage(result.error.message) + } } + } } - ) + }) - if (showPrimaryChangeAlertDialog) { - AlertDialog( - onDismissRequest = { + AnimatedVisibility(showPrimaryChangeAlertDialog) { + AlertDialog( + onDismissRequest = { showPrimaryChangeAlertDialog = false }, + confirmButton = { + TextButton( + onClick = { + viewModel.onDefaultTunnelChange(selectedTunnel) showPrimaryChangeAlertDialog = false - }, - confirmButton = { - TextButton(onClick = { - scope.launch { - viewModel.onDefaultTunnelChange(selectedTunnel) - showPrimaryChangeAlertDialog = false - selectedTunnel = null - } - }) { Text(text = stringResource(R.string.okay)) } - }, - dismissButton = { - TextButton(onClick = { - showPrimaryChangeAlertDialog = false - }) { Text(text = stringResource(R.string.cancel)) } - }, - title = { Text(text = stringResource(R.string.primary_tunnel_change)) }, - text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) } - ) - } - - fun onTunnelToggle( - checked: Boolean, - tunnel: TunnelConfig - ) { - try { - if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() - } catch (e: Exception) { - showSnackbarMessage(e.message!!) - } - } - - Scaffold( - modifier = - Modifier.pointerInput(Unit) { - detectTapGestures(onTap = { selectedTunnel = null - }) + }) { + Text(text = stringResource(R.string.okay)) + } }, - floatingActionButtonPosition = FabPosition.End, - floatingActionButton = { - AnimatedVisibility( - visible = isVisible.value, - enter = slideInVertically(initialOffsetY = { it * 2 }), - exit = slideOutVertically(targetOffsetY = { it * 2 }) - ) { - val secondaryColor = MaterialTheme.colorScheme.secondary - val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) - var fobColor by remember { mutableStateOf(secondaryColor) } - FloatingActionButton( - modifier = - Modifier - .padding(bottom = 90.dp) - .onFocusChanged { - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - fobColor = if (it.isFocused) hoverColor else secondaryColor + dismissButton = { + TextButton(onClick = { showPrimaryChangeAlertDialog = false }) { + Text(text = stringResource(R.string.cancel)) + } + }, + title = { Text(text = stringResource(R.string.primary_tunnel_change)) }, + text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }) + } + + fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) { + if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + } + + Scaffold( + modifier = + Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { + if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null + }) + }, + floatingActionButtonPosition = FabPosition.End, + topBar = { + if (uiState.settings.isAutoTunnelEnabled) + TopAppBar( + title = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.requiredWidth(LocalConfiguration.current.screenWidthDp.dp).padding(end = 5.dp)) { + Row { + Icon( + Icons.Rounded.Bolt, + stringResource(id = R.string.auto), + modifier = Modifier.size(25.dp), + tint = if(uiState.settings.isAutoTunnelPaused) Color.Gray else mint) + Text( + "Auto-tunneling: ${if(uiState.settings.isAutoTunnelPaused) "paused" else "active" }", + style = typography.bodyLarge, + modifier = Modifier.padding(start = 10.dp)) + } + if(uiState.settings.isAutoTunnelPaused) TextButton( + onClick = { viewModel.resumeAutoTunneling() }, + modifier = Modifier.padding(end = 10.dp)) { + Text("Resume") + } else TextButton( + onClick = { viewModel.pauseAutoTunneling() }, + modifier = Modifier.padding(end = 10.dp)) { + Text("Pause") + } + } + }, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = isVisible.value, + enter = slideInVertically(initialOffsetY = { it * 2 }), + exit = slideOutVertically(targetOffsetY = { it * 2 })) { + val secondaryColor = MaterialTheme.colorScheme.secondary + val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + var fobColor by remember { mutableStateOf(secondaryColor) } + FloatingActionButton( + modifier = + (if (WireGuardAutoTunnel.isRunningOnAndroidTv() && + uiState.tunnels.isEmpty()) + Modifier.focusRequester(focusRequester) + else Modifier) + .padding(bottom = 90.dp) + .onFocusChanged { + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + fobColor = if (it.isFocused) hoverColor else secondaryColor } - }, - onClick = { - showBottomSheet = true - }, - containerColor = fobColor, - shape = RoundedCornerShape(16.dp) - ) { + }, + onClick = { showBottomSheet = true }, + containerColor = fobColor, + shape = RoundedCornerShape(16.dp)) { Icon( imageVector = Icons.Rounded.Add, contentDescription = stringResource(id = R.string.add_tunnel), - tint = Color.DarkGray - ) - } + tint = Color.DarkGray) + } } - } - ) { - if (tunnels.isEmpty()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = - Modifier - .fillMaxSize() - .padding(padding) - ) { + }) { innerPadding -> + AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize().padding(padding)) { Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) - } + } } if (showBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - showBottomSheet = false - }, - sheetState = sheetState - ) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, sheetState = sheetState) { // Sheet content Row( modifier = - Modifier - .fillMaxWidth() - .clickable { - showBottomSheet = false - try { - tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES) - } catch (e: Exception) { - showSnackbarMessage(e.message!!) - } - } - .padding(10.dp) - ) { - Icon( - Icons.Filled.FileOpen, - contentDescription = stringResource(id = R.string.open_file), - modifier = Modifier.padding(10.dp) - ) - Text( - stringResource(id = R.string.add_tunnels_text), - modifier = Modifier.padding(10.dp) - ) - } - if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Divider() - Row( - modifier = - Modifier - .fillMaxWidth() + Modifier.fillMaxWidth() .clickable { - scope.launch { - showBottomSheet = false - val scanOptions = ScanOptions() - scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) - scanOptions.setOrientationLocked(true) - scanOptions.setPrompt(context.getString(R.string.scanning_qr)) - scanOptions.setBeepEnabled(false) - scanOptions.captureActivity = - CaptureActivityPortrait::class.java - scanLauncher.launch(scanOptions) - } + showBottomSheet = false + tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES) } - .padding(10.dp) - ) { + .padding(10.dp)) { + Icon( + Icons.Filled.FileOpen, + contentDescription = stringResource(id = R.string.open_file), + modifier = Modifier.padding(10.dp)) + Text( + stringResource(id = R.string.add_tunnels_text), + modifier = Modifier.padding(10.dp)) + } + if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Divider() + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + scope.launch { + showBottomSheet = false + val scanOptions = ScanOptions() + scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) + scanOptions.setOrientationLocked(true) + scanOptions.setPrompt(context.getString(R.string.scanning_qr)) + scanOptions.setBeepEnabled(false) + scanOptions.captureActivity = CaptureActivityPortrait::class.java + scanLauncher.launch(scanOptions) + } + } + .padding(10.dp)) { Icon( Icons.Filled.QrCode, contentDescription = stringResource(id = R.string.qr_scan), - modifier = Modifier.padding(10.dp) - ) + modifier = Modifier.padding(10.dp)) Text( stringResource(id = R.string.add_from_qr), - modifier = Modifier.padding(10.dp) - ) - } + modifier = Modifier.padding(10.dp)) + } } Divider() Row( modifier = - Modifier - .fillMaxWidth() - .clickable { - showBottomSheet = false - navController.navigate( - "${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}" - ) - } - .padding(10.dp) - ) { - Icon( - Icons.Filled.Create, - contentDescription = stringResource(id = R.string.create_import), - modifier = Modifier.padding(10.dp) - ) - Text( - stringResource(id = R.string.create_import), - modifier = Modifier.padding(10.dp) - ) - } - } + Modifier.fillMaxWidth() + .clickable { + showBottomSheet = false + navController.navigate( + "${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") + } + .padding(10.dp)) { + Icon( + Icons.Filled.Create, + contentDescription = stringResource(id = R.string.create_import), + modifier = Modifier.padding(10.dp)) + Text( + stringResource(id = R.string.create_import), + modifier = Modifier.padding(10.dp)) + } + } } - Column( + + LazyColumn( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, modifier = - Modifier - .fillMaxSize() - .padding(padding) - ) { - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(top = 10.dp) - .nestedScroll(nestedScrollConnection) - ) { - items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> - val leadingIconColor = ( - if (tunnelName == tunnel.name) { - when (handshakeStatus) { - HandshakeStatus.HEALTHY -> mint - HandshakeStatus.UNHEALTHY -> brickRed - HandshakeStatus.STALE -> corn - HandshakeStatus.NOT_STARTED -> Color.Gray - HandshakeStatus.NEVER_CONNECTED -> brickRed + Modifier.fillMaxWidth().fillMaxHeight(.90f).overscroll(ScrollableDefaults.overscrollEffect()).padding(innerPadding), + state = rememberLazyListState(0, uiState.tunnels.count()), + userScrollEnabled = true, + reverseLayout = true, + flingBehavior = ScrollableDefaults.flingBehavior()) { + items(uiState.tunnels, + key = { tunnel -> tunnel.id }) { tunnel -> + val leadingIconColor = + (if (uiState.vpnState.name == tunnel.name && + uiState.vpnState.status == Tunnel.State.UP) { + uiState.vpnState.statistics + ?.mapPeerStats() + ?.map { it.value?.handshakeStatus() } + .let { statuses -> + when { + statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint + statuses?.any { it == HandshakeStatus.STALE } == true -> corn + statuses?.all { it == HandshakeStatus.NOT_STARTED } == true -> + Color.Gray + else -> { + Color.Gray + } } - } else { - Color.Gray - } - ) - val focusRequester = remember { FocusRequester() } - val expanded = - remember { - mutableStateOf(false) - } + } + } else { + Color.Gray + }) + val expanded = remember { mutableStateOf(false) } RowListItem( icon = { - if (settings.isTunnelConfigDefault(tunnel)) { - Icon( - Icons.Rounded.Star, - stringResource(R.string.status), - tint = leadingIconColor, - modifier = - Modifier - .padding(end = 10.dp) - .size(20.dp) - ) - } else { - Icon( - Icons.Rounded.Circle, - stringResource(R.string.status), - tint = leadingIconColor, - modifier = - Modifier - .padding(end = 15.dp) - .size(15.dp) - ) - } + if (uiState.settings.isTunnelConfigDefault(tunnel)) { + Icon( + Icons.Rounded.Star, + stringResource(R.string.status), + tint = leadingIconColor, + modifier = Modifier.padding(end = 10.dp).size(20.dp)) + } else { + Icon( + Icons.Rounded.Circle, + stringResource(R.string.status), + tint = leadingIconColor, + modifier = Modifier.padding(end = 15.dp).size(15.dp)) + } }, text = tunnel.name, onHold = { - if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) { - showSnackbarMessage( - context.resources.getString(R.string.turn_off_tunnel) - ) - return@RowListItem - } - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - selectedTunnel = tunnel + if ((uiState.vpnState.status == Tunnel.State.UP) && + (tunnel.name == uiState.vpnState.name)) { + showSnackbarMessage(Event.Message.TunnelOffAction.message) + return@RowListItem + } + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + selectedTunnel = tunnel }, onClick = { - if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) { - expanded.value = !expanded.value - } - } else { - selectedTunnel = tunnel - focusRequester.requestFocus() + if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { + if (uiState.vpnState.status == Tunnel.State.UP && + (uiState.vpnState.name == tunnel.name)) { + expanded.value = !expanded.value } + } else { + selectedTunnel = tunnel + focusRequester.requestFocus() + } }, - statistics = statistics, + statistics = uiState.vpnState.statistics, expanded = expanded.value, rowButton = { - if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv( - context - ) - ) { - Row { - if (!settings.isTunnelConfigDefault(tunnel)) { - IconButton(onClick = { - if (settings.isAutoTunnelEnabled) { - showSnackbarMessage( - context.resources.getString( - R.string.turn_off_auto - ) - ) - } else { - showPrimaryChangeAlertDialog = true - } - }) { - Icon( - Icons.Rounded.Star, - stringResource(id = R.string.set_primary) - ) - } - } - IconButton(onClick = { - navController.navigate( - "${Routes.Config.name}/${selectedTunnel?.id}" - ) + if (tunnel.id == selectedTunnel?.id && + !WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Row { + if (!uiState.settings.isTunnelConfigDefault(tunnel)) { + IconButton( + onClick = { + if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) { + showSnackbarMessage( + Event.Message.AutoTunnelOffAction.message) + } else { + showPrimaryChangeAlertDialog = true + } }) { - Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) + Icon( + Icons.Rounded.Star, + stringResource(id = R.string.set_primary)) } - IconButton( - modifier = Modifier.focusable(), - onClick = { viewModel.onDelete(tunnel) } - ) { - Icon( - Icons.Rounded.Delete, - stringResource(id = R.string.delete) - ) - } - } - } else { - val checked = state == Tunnel.State.UP && tunnel.name == tunnelName - if (!checked) expanded.value = false - - @Composable - fun TunnelSwitch() = - Switch( - modifier = Modifier.focusRequester(focusRequester), - checked = checked, - onCheckedChange = { checked -> - if (!checked) expanded.value = false - onTunnelToggle(checked, tunnel) - } - ) - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Row { - if (!settings.isTunnelConfigDefault(tunnel)) { - IconButton(onClick = { - if (settings.isAutoTunnelEnabled) { - showSnackbarMessage( - context.resources.getString( - R.string.turn_off_auto - ) - ) - } else { - showPrimaryChangeAlertDialog = true - } - }) { - Icon( - Icons.Rounded.Star, - stringResource(id = R.string.set_primary) - ) - } - } - IconButton( - modifier = Modifier.focusRequester(focusRequester), - onClick = { - if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) { - expanded.value = !expanded.value - } - } - ) { - Icon(Icons.Rounded.Info, stringResource(R.string.info)) - } - IconButton(onClick = { - if (state == Tunnel.State.UP && tunnel.name == tunnelName) { - showSnackbarMessage( - context.resources.getString( - R.string.turn_off_tunnel - ) - ) - } else { - navController.navigate( - "${Routes.Config.name}/${tunnel.id}" - ) - } - }) { - Icon( - Icons.Rounded.Edit, - stringResource(id = R.string.edit) - ) - } - IconButton(onClick = { - if (state == Tunnel.State.UP && tunnel.name == tunnelName) { - showSnackbarMessage( - context.resources.getString( - R.string.turn_off_tunnel - ) - ) - } else { - viewModel.onDelete(tunnel) - } - }) { - Icon( - Icons.Rounded.Delete, - stringResource(id = R.string.delete) - ) - } - TunnelSwitch() - } - } else { - TunnelSwitch() - } + } + IconButton( + onClick = { + if (uiState.settings.isAutoTunnelEnabled && uiState.settings.isTunnelConfigDefault(tunnel) + && !uiState.settings.isAutoTunnelPaused) { + showSnackbarMessage( + Event.Message.AutoTunnelOffAction.message) + } else navController.navigate( + "${Screen.Config.route}/${selectedTunnel?.id}") + }) { + Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) + } + IconButton( + modifier = Modifier.focusable(), + onClick = { viewModel.onDelete(tunnel) }) { + Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete)) + } } - } - ) - } + } else { + val checked by remember { + derivedStateOf { + (uiState.vpnState.status == Tunnel.State.UP && + tunnel.name == uiState.vpnState.name) + } + } + if (!checked) expanded.value = false + + @Composable + fun TunnelSwitch() = + Switch( + modifier = Modifier.focusRequester(focusRequester), + checked = checked, + onCheckedChange = { checked -> + if (!checked) expanded.value = false + onTunnelToggle(checked, tunnel) + }) + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Row { + if (!uiState.settings.isTunnelConfigDefault(tunnel)) { + IconButton( + onClick = { + if (uiState.settings.isAutoTunnelEnabled) { + showSnackbarMessage( + Event.Message.AutoTunnelOffAction.message) + } else { + selectedTunnel = tunnel + showPrimaryChangeAlertDialog = true + } + }) { + Icon( + Icons.Rounded.Star, + stringResource(id = R.string.set_primary)) + } + } + IconButton( + modifier = Modifier.focusRequester(focusRequester), + onClick = { + if (uiState.vpnState.status == Tunnel.State.UP && + (uiState.vpnState.name == tunnel.name)) { + expanded.value = !expanded.value + } else { + showSnackbarMessage(Event.Message.TunnelOnAction.message) + } + }) { + Icon(Icons.Rounded.Info, stringResource(R.string.info)) + } + IconButton( + onClick = { + if (uiState.vpnState.status == Tunnel.State.UP && + tunnel.name == uiState.vpnState.name) { + showSnackbarMessage(Event.Message.TunnelOffAction.message) + } else { + navController.navigate( + "${Screen.Config.route}/${tunnel.id}") + } + }) { + Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) + } + IconButton( + onClick = { + if (uiState.vpnState.status == Tunnel.State.UP && + tunnel.name == uiState.vpnState.name) { + showSnackbarMessage(Event.Message.TunnelOffAction.message) + } else { + viewModel.onDelete(tunnel) + } + }) { + Icon( + Icons.Rounded.Delete, + stringResource(id = R.string.delete)) + } + TunnelSwitch() + } + } else { + TunnelSwitch() + } + } + }) + } } - } - } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt new file mode 100644 index 0000000..3246a25 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main + +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState +import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs + +data class MainUiState( + val settings : Settings = Settings(), + val tunnels : TunnelConfigs = emptyList(), + val vpnState: VpnState = VpnState(), + val loading : Boolean = true +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index b0454a9..9965d12 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -8,267 +8,254 @@ import android.provider.OpenableColumns import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.config.Config -import com.zaneschepke.wireguardautotunnel.Constants -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.model.Settings -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService +import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException +import com.zaneschepke.wireguardautotunnel.util.Result import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.InputStream import java.util.zip.ZipInputStream import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @HiltViewModel class MainViewModel @Inject constructor( private val application: Application, - private val tunnelRepo: TunnelConfigDao, - private val settingsRepo: SettingsDoa, + private val tunnelConfigRepository: TunnelConfigRepository, + private val settingsRepository: SettingsRepository, private val vpnService: VpnService ) : ViewModel() { - val tunnels get() = tunnelRepo.getAllFlow() - val state get() = vpnService.state - val handshakeStatus get() = vpnService.handshakeStatus - val tunnelName get() = vpnService.tunnelName - private val _settings = MutableStateFlow(Settings()) - val settings get() = _settings.asStateFlow() - val statistics get() = vpnService.statistics + val uiState = + combine( + settingsRepository.getSettingsFlow(), + tunnelConfigRepository.getTunnelConfigsFlow(), + vpnService.vpnState, + ) { settings, tunnels, vpnState -> + validateWatcherServiceState(settings) + MainUiState(settings, tunnels, vpnState, false) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), + MainUiState()) - init { - viewModelScope.launch(Dispatchers.IO) { - settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { - val settings = it.first() - validateWatcherServiceState(settings) - _settings.emit(settings) - } - } + private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) { + val watcherState = + ServiceManager.getServiceState( + application.applicationContext, WireGuardConnectivityWatcherService::class.java) + if (settings.isAutoTunnelEnabled && + watcherState == ServiceState.STOPPED) { + ServiceManager.startWatcherService(application.applicationContext) } + } - private fun validateWatcherServiceState(settings: Settings) { - val watcherState = - ServiceManager.getServiceState( - application.applicationContext, - WireGuardConnectivityWatcherService::class.java - ) - if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) { - ServiceManager.startWatcherService( - application.applicationContext, - settings.defaultTunnel!! - ) - } + private fun stopWatcherService() = viewModelScope.launch(Dispatchers.IO) { + ServiceManager.stopWatcherService(application.applicationContext) } - - fun onDelete(tunnel: TunnelConfig) { - viewModelScope.launch { - if (tunnelRepo.count() == 1L) { - ServiceManager.stopWatcherService(application.applicationContext) - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings[0] - setting.defaultTunnel = null - setting.isAutoTunnelEnabled = false - setting.isAlwaysOnVpnEnabled = false - settingsRepo.save(setting) - } - } - tunnelRepo.delete(tunnel) - } + fun onDelete(tunnel: TunnelConfig) { + viewModelScope.launch(Dispatchers.IO) { + if (tunnelConfigRepository.count() == 1) { + stopWatcherService() + val settings = settingsRepository.getSettings() + settings.defaultTunnel = null + settings.isAutoTunnelEnabled = false + settings.isAlwaysOnVpnEnabled = false + saveSettings(settings) + } + tunnelConfigRepository.delete(tunnel) + WireGuardAutoTunnel.requestTileServiceStateUpdate() } + } - fun onTunnelStart(tunnelConfig: TunnelConfig) { - viewModelScope.launch { - stopActiveTunnel() - startTunnel(tunnelConfig) - } - } + fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) { + stopActiveTunnel().await() + startTunnel(tunnelConfig) + } - private fun startTunnel(tunnelConfig: TunnelConfig) { - ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) - } + private fun startTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) { + ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) + } - private suspend fun stopActiveTunnel() { + private fun stopActiveTunnel() = + viewModelScope.async(Dispatchers.IO) { if (ServiceManager.getServiceState( - application.applicationContext, - WireGuardTunnelService::class.java - ) == ServiceState.STARTED - ) { - onTunnelStop() - delay(Constants.TOGGLE_TUNNEL_DELAY) + application.applicationContext, WireGuardTunnelService::class.java) == + ServiceState.STARTED) { + onTunnelStop() + delay(Constants.TOGGLE_TUNNEL_DELAY) } - } + } - fun onTunnelStop() { - ServiceManager.stopVpnService(application.applicationContext) - } + fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) { + ServiceManager.stopVpnService(application.applicationContext) + } - private fun validateConfigString(config: String) { - TunnelConfig.configFromQuick(config) - } + private fun validateConfigString(config: String) { + TunnelConfig.configFromQuick(config) + } - suspend fun onTunnelQrResult(result: String) { - try { - validateConfigString(result) - val tunnelConfig = - TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) - addTunnel(tunnelConfig) + suspend fun onTunnelQrResult(result: String) : Result { + return try { + validateConfigString(result) + val tunnelConfig = + TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) + addTunnel(tunnelConfig) + Result.Success(Unit) } catch (e: Exception) { - throw WgTunnelException(e) + Result.Error(Event.Error.InvalidQrCode) } - } + } - private suspend fun saveTunnelConfigFromStream( - stream: InputStream, - fileName: String - ) { - val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) - val config = Config.parse(bufferReader) - val tunnelName = getNameFromFileName(fileName) - addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) - withContext(Dispatchers.IO) { - stream.close() - } - } + private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { + val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) + val config = Config.parse(bufferReader) + val tunnelName = getNameFromFileName(fileName) + addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) + withContext(Dispatchers.IO) { stream.close() } + } - private fun getInputStreamFromUri(uri: Uri): InputStream { - return application.applicationContext.contentResolver.openInputStream(uri) - ?: throw WgTunnelException(application.getString(R.string.stream_failed)) - } + private fun getInputStreamFromUri(uri: Uri): InputStream? { + return application.applicationContext.contentResolver.openInputStream(uri) + } - suspend fun onTunnelFileSelected(uri: Uri) { + suspend fun onTunnelFileSelected(uri: Uri) : Result { try { - val fileName = getFileName(application.applicationContext, uri) - val fileExtension = getFileExtensionFromFileName(fileName) - when (fileExtension) { - Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri) - Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) - else -> throw WgTunnelException( - application.getString(R.string.file_extension_message) - ) - } - } catch (e: Exception) { - throw WgTunnelException(e) - } - } - - private suspend fun saveTunnelsFromZipUri(uri: Uri) { - ZipInputStream(getInputStreamFromUri(uri)).use { zip -> - generateSequence { zip.nextEntry } - .filterNot { - it.isDirectory || - getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION - } - .forEach { - val name = getNameFromFileName(it.name) - val config = Config.parse(zip) - viewModelScope.launch(Dispatchers.IO) { - addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString())) + if(isValidUriContentScheme(uri)){ + val fileName = getFileName(application.applicationContext, uri) + when (getFileExtensionFromFileName(fileName)) { + Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let { + when(it) { + is Result.Error -> return Result.Error(Event.Error.FileReadFailed) + is Result.Success -> return it + } } + Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) + else -> return Result.Error(Event.Error.InvalidFileExtension) } - } - } - - private suspend fun saveTunnelFromConfUri( - name: String, - uri: Uri - ) { - val stream = getInputStreamFromUri(uri) - saveTunnelConfigFromStream(stream, name) - } - - private suspend fun addTunnel(tunnelConfig: TunnelConfig) { - saveTunnel(tunnelConfig) - } - - private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { - tunnelRepo.save(tunnelConfig) - } - - private fun getFileNameByCursor( - context: Context, - uri: Uri - ): String { - val cursor = context.contentResolver.query(uri, null, null, null, null) - if (cursor != null) { - cursor.use { - return getDisplayNameByCursor(it) + return Result.Success(Unit) + } else { + return Result.Error(Event.Error.InvalidFileExtension) } - } else { - throw WgTunnelException("Failed to initialize cursor") - } - } - - private fun getDisplayNameColumnIndex(cursor: Cursor): Int { - val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (columnIndex == -1) { - throw WgTunnelException("Cursor out of bounds") - } - return columnIndex - } - - private fun getDisplayNameByCursor(cursor: Cursor): String { - if (cursor.moveToFirst()) { - val index = getDisplayNameColumnIndex(cursor) - return cursor.getString(index) - } else { - throw WgTunnelException("Cursor failed to move to first") - } - } - - private fun validateUriContentScheme(uri: Uri) { - if (uri.scheme != Constants.URI_CONTENT_SCHEME) { - throw WgTunnelException(application.getString(R.string.file_extension_message)) - } - } - - private fun getFileName( - context: Context, - uri: Uri - ): String { - validateUriContentScheme(uri) - return try { - getFileNameByCursor(context, uri) - } catch (_: Exception) { - NumberUtils.generateRandomTunnelName() - } - } - - private fun getNameFromFileName(fileName: String): String { - return fileName.substring(0, fileName.lastIndexOf('.')) - } - - private fun getFileExtensionFromFileName(fileName: String): String { - return try { - fileName.substring(fileName.lastIndexOf('.')) } catch (e: Exception) { - "" + return Result.Error(Event.Error.FileReadFailed) } + } + + private suspend fun saveTunnelsFromZipUri(uri: Uri) { + ZipInputStream(getInputStreamFromUri(uri)).use { zip -> + generateSequence { zip.nextEntry } + .filterNot { + it.isDirectory || getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION + } + .forEach { + val name = getNameFromFileName(it.name) + val config = Config.parse(zip) + viewModelScope.launch(Dispatchers.IO) { + addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString())) + } + } + } + } + + private suspend fun saveTunnelFromConfUri(name: String, uri: Uri) : Result { + val stream = getInputStreamFromUri(uri) + return if(stream != null) { + saveTunnelConfigFromStream(stream, name) + Result.Success(Unit) + } else { + Result.Error(Event.Error.FileReadFailed) + } + } + + private suspend fun addTunnel(tunnelConfig: TunnelConfig) { + saveTunnel(tunnelConfig) + WireGuardAutoTunnel.requestTileServiceStateUpdate() + } + + fun pauseAutoTunneling() = viewModelScope.launch { + settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true)) } - suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { - if (selectedTunnel != null) { - _settings.emit( - _settings.value.copy( - defaultTunnel = selectedTunnel.toString() - ) - ) - settingsRepo.save(_settings.value) - } + fun resumeAutoTunneling() = viewModelScope.launch { + settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false)) } + + private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { + tunnelConfigRepository.save(tunnelConfig) + } + + private fun getFileNameByCursor(context: Context, uri: Uri): String? { + context.contentResolver.query(uri, null, null, null, null)?.use { + return getDisplayNameByCursor(it) + } + return null + } + + private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { + val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + return if (columnIndex != -1) { + return columnIndex + } else { + null + } + } + + private fun getDisplayNameByCursor(cursor: Cursor): String? { + return if (cursor.moveToFirst()) { + val index = getDisplayNameColumnIndex(cursor) + if (index != null) { + cursor.getString(index) + } else null + } else null + } + + private fun isValidUriContentScheme(uri: Uri): Boolean { + return uri.scheme == Constants.URI_CONTENT_SCHEME + } + private fun getFileName(context: Context, uri: Uri): String { + return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName() + } + + private fun getNameFromFileName(fileName: String): String { + return fileName.substring(0, fileName.lastIndexOf('.')) + } + + private fun getFileExtensionFromFileName(fileName: String): String { + return try { + fileName.substring(fileName.lastIndexOf('.')) + } catch (e: Exception) { + "" + } + } + + private fun saveSettings(settings: Settings) = + viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) } + + fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) = viewModelScope.launch { + if (selectedTunnel != null) { + saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString())).join() + WireGuardAutoTunnel.requestTileServiceStateUpdate() + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index 7250ffc..f5d053d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -4,7 +4,7 @@ import android.Manifest import android.content.Intent import android.net.Uri import android.os.Build -import android.provider.Settings +import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.rounded.LocationOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -45,15 +46,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.ImeAction @@ -70,21 +69,21 @@ import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.WgQuickBackend import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt +import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle +import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.FileUtils -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException -import java.io.File +import com.zaneschepke.wireguardautotunnel.util.Result import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.io.File + @OptIn( ExperimentalPermissionsApi::class, - ExperimentalLayoutApi::class, - ExperimentalComposeUiApi::class -) + ExperimentalLayoutApi::class) @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), @@ -92,505 +91,418 @@ fun SettingsScreen( showSnackbarMessage: (String) -> Unit, focusRequester: FocusRequester ) { - val scope = rememberCoroutineScope { Dispatchers.IO } - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val interactionSource = remember { MutableInteractionSource() } + val scope = rememberCoroutineScope { Dispatchers.IO } + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val scrollState = rememberScrollState() + val interactionSource = remember { MutableInteractionSource() } - val settings by viewModel.settings.collectAsStateWithLifecycle() - val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle() - val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) - val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) - var currentText by remember { mutableStateOf("") } - val scrollState = rememberScrollState() - var isBackgroundLocationGranted by remember { mutableStateOf(true) } - var showAuthPrompt by remember { mutableStateOf(false) } - var didExportFiles by remember { mutableStateOf(false) } - val isLocationDisclosureShown by viewModel.disclosureShown.collectAsStateWithLifecycle( - null - ) - val vpnState = viewModel.vpnState.collectAsStateWithLifecycle(initialValue = Tunnel.State.DOWN) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val screenPadding = 5.dp - val fillMaxWidth = .85f + 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 didExportFiles by remember { mutableStateOf(false) } + var showAuthPrompt by remember { mutableStateOf(false) } + val focusRequester2 = remember { FocusRequester() } - fun setLocationDisclosureShown() = scope.launch { - viewModel.dataStoreManager.saveToDataStore( - DataStoreManager.LOCATION_DISCLOSURE_SHOWN, - true - ) + val screenPadding = 5.dp + val fillMaxWidth = .85f + + if (uiState.loading) { + LoadingScreen() + return + } + + fun exportAllConfigs() { + try { + val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") } + files.forEachIndexed { index, file -> + file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) } + } + FileUtils.saveFilesToZip(context, files) + didExportFiles = true + showSnackbarMessage(Event.Message.ConfigsExported.message) + } catch (e: Exception) { + showSnackbarMessage(Event.Error.Exception(e).message) } + } - fun exportAllConfigs() { - try { - val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } - files.forEachIndexed { index, file -> - file.outputStream().use { - it.write(tunnels[index].wgQuick.toByteArray()) - } - } - FileUtils.saveFilesToZip(context, files) - didExportFiles = true - showSnackbarMessage(context.getString(R.string.exported_configs_message)) - } catch (e: Exception) { - showSnackbarMessage(e.message!!) - } + fun saveTrustedSSID() { + if (currentText.isNotEmpty()) { + viewModel.onSaveTrustedSSID(currentText).let { + when(it) { + is Result.Success -> currentText = "" + is Result.Error -> showSnackbarMessage(it.error.message) + } + } } + } - fun saveTrustedSSID() { - if (currentText.isNotEmpty()) { - scope.launch { - try { - viewModel.onSaveTrustedSSID(currentText) - currentText = "" - } catch (e: Exception) { - showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error)) - } - } - } + fun openSettings() { + scope.launch { + val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS) + intentSettings.data = Uri.fromParts("package", context.packageName, null) + context.startActivity(intentSettings) } + } - fun isAllAutoTunnelPermissionsEnabled(): Boolean { - return ( - isBackgroundLocationGranted && - fineLocationState.status.isGranted && - !viewModel.isLocationServicesNeeded() - ) - } - - fun openSettings() { - scope.launch { - val intentSettings = - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intentSettings.data = - Uri.fromParts("package", context.packageName, null) - context.startActivity(intentSettings) - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val backgroundLocationState = - rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) - isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) { - false - } else { - SideEffect { - setLocationDisclosureShown() - } - true - } - } - - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + fun checkFineLocationGranted() { + isBackgroundLocationGranted = if (!fineLocationState.status.isGranted) { - isBackgroundLocationGranted = false + false } else { - SideEffect { - setLocationDisclosureShown() - } - isBackgroundLocationGranted = true + viewModel.setLocationDisclosureShown() + true } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if(WireGuardAutoTunnel.isRunningOnAndroidTv() && 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() + } + + AnimatedVisibility(showLocationServicesAlertDialog) { + AlertDialog( + onDismissRequest = { showLocationServicesAlertDialog = false }, + confirmButton = { + TextButton( + onClick = { + showLocationServicesAlertDialog = false + viewModel.toggleAutoTunnel() + }) { + Text(text = stringResource(R.string.okay)) + } + }, + dismissButton = { + TextButton(onClick = { showLocationServicesAlertDialog = false }) { + Text(text = stringResource(R.string.cancel)) + } + }, + title = { Text(text = stringResource(R.string.location_services_not_detected)) }, + text = { Text(text = stringResource(R.string.location_services_missing_message)) }) } - if (isLocationDisclosureShown != true) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(padding) - ) { - 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 (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Modifier - .fillMaxWidth() - .padding(10.dp) - } else { - Modifier - .fillMaxWidth() - .padding(30.dp) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly - ) { - TextButton(onClick = { - setLocationDisclosureShown() + if (!uiState.isLocationDisclosureShown) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) { + 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 (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Modifier.fillMaxWidth().padding(10.dp) + } else { + Modifier.fillMaxWidth().padding(30.dp) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly) { + TextButton(onClick = { viewModel.setLocationDisclosureShown() }) { + Text(stringResource(id = R.string.no_thanks)) + } + TextButton( + modifier = Modifier.focusRequester(focusRequester), + onClick = { + openSettings() + viewModel.setLocationDisclosureShown() }) { - Text(stringResource(id = R.string.no_thanks)) + Text(stringResource(id = R.string.turn_on)) } - TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = { - openSettings() - setLocationDisclosureShown() - }) { - Text(stringResource(id = R.string.turn_on)) + } + } + } + + if(showAuthPrompt) { + AuthorizationPrompt( + onSuccess = { + showAuthPrompt = false + exportAllConfigs() + }, + onError = { _ -> + showAuthPrompt = false + showSnackbarMessage(Event.Error.AuthenticationFailed.message) + }, + onFailure = { + showAuthPrompt = false + showSnackbarMessage(Event.Error.AuthorizationFailed.message) + }) + } + + if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize().padding(padding)) { + Text( + stringResource(R.string.one_tunnel_required), + textAlign = TextAlign.Center, + modifier = Modifier.padding(15.dp), + fontStyle = FontStyle.Italic) + } + } + if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = + Modifier.fillMaxSize().verticalScroll(scrollState).clickable( + indication = null, interactionSource = interactionSource) { + focusManager.clearFocus() + }) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth(fillMaxWidth) + .padding(top = 10.dp) + } else { + Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.dp) + }) + .padding(bottom = 10.dp)) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp)) { + SectionTitle( + title = stringResource(id = R.string.auto_tunneling), + padding = screenPadding) + ConfigurationToggle( + stringResource(id = R.string.tunnel_on_wifi), + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled), + checked = uiState.settings.isTunnelOnWifiEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnWifi() }, + modifier = if(uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester).focusProperties { down = focusRequester2 }) + AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) { + Column { + FlowRow( + modifier = Modifier.padding(screenPadding).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp)) { + uiState.settings.trustedNetworkSSIDs.forEach { ssid -> + ClickableIconButton( + onClick = { if(WireGuardAutoTunnel.isRunningOnAndroidTv()) { + viewModel.onDeleteTrustedSSID(ssid) + focusRequester2.requestFocus() + }}, + onIconClick = { viewModel.onDeleteTrustedSSID(ssid) }, + text = ssid, + icon = Icons.Filled.Close, + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled)) + } + if (uiState.settings.trustedNetworkSSIDs.isEmpty()) { + Text( + stringResource(R.string.none), + fontStyle = FontStyle.Italic, + color = Color.Gray) + } + } + OutlinedTextField( + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled), + value = currentText, + onValueChange = { currentText = it }, + label = { Text(stringResource(R.string.add_trusted_ssid)) }, + modifier = + Modifier.padding( + start = screenPadding, top = 5.dp, bottom = 10.dp) + .focusRequester(focusRequester2) + , + 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.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled), + checked = uiState.settings.isTunnelOnMobileDataEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnMobileData() }) + ConfigurationToggle( + stringResource(id = R.string.tunnel_on_ethernet), + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled), + checked = uiState.settings.isTunnelOnEthernetEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnEthernet() }) + ConfigurationToggle( + stringResource(R.string.battery_saver), + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled), + checked = uiState.settings.isBatterySaverEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleBatterySaver() }) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = (if(!uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester)) + .fillMaxSize().padding(top = 5.dp), + horizontalArrangement = Arrangement.Center) { + TextButton( + enabled = !uiState.settings.isAlwaysOnVpnEnabled, + onClick = { + if (uiState.settings.isTunnelOnWifiEnabled && !uiState.settings.isAutoTunnelEnabled) { + when(false) { + isBackgroundLocationGranted -> + showSnackbarMessage(Event.Error.BackgroundLocationRequired.message) + fineLocationState.status.isGranted -> + showSnackbarMessage(Event.Error.PreciseLocationRequired.message) + viewModel.isLocationEnabled(context) -> + showLocationServicesAlertDialog = true + else -> { + viewModel.toggleAutoTunnel() + } + } + } else { + viewModel.toggleAutoTunnel() + } + }) { + val autoTunnelButtonText = + if (uiState.settings.isAutoTunnelEnabled) { + stringResource(R.string.disable_auto_tunnel) + } else { + stringResource(id = R.string.enable_auto_tunnel) + } + Text(autoTunnelButtonText) + } + } } + } + if (WgQuickBackend.hasKernelSupport()) { + 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.kernel), padding = screenPadding) + ConfigurationToggle( + stringResource(R.string.use_kernel), + enabled = + !(uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled || + (uiState.vpnState.status == Tunnel.State.UP)), + checked = uiState.settings.isKernelEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleKernelMode().let { + when(it) { + is Result.Error -> showSnackbarMessage(it.error.message) + is Result.Success -> {} + } + } }) + } } - } - return - } - - if (showAuthPrompt) { - AuthorizationPrompt( - onSuccess = { - showAuthPrompt = false - exportAllConfigs() - }, - onError = { error -> - showSnackbarMessage(error) - showAuthPrompt = false - }, - onFailure = { - showAuthPrompt = false - showSnackbarMessage(context.getString(R.string.authentication_failed)) - } - ) - } - - if (tunnels.isEmpty()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = - Modifier - .fillMaxSize() - .padding(padding) - ) { - Text( - stringResource(R.string.one_tunnel_required), - textAlign = TextAlign.Center, - modifier = Modifier.padding(15.dp), - fontStyle = FontStyle.Italic - ) - } - return - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .clickable(indication = null, interactionSource = interactionSource) { - focusManager.clearFocus() - } - ) { + } + if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { Surface( tonalElevation = 2.dp, shadowElevation = 2.dp, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface, modifier = - ( - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Modifier - .height(IntrinsicSize.Min) - .fillMaxWidth(fillMaxWidth) - .padding(top = 10.dp) - } else { - Modifier - .fillMaxWidth(fillMaxWidth) - .padding(top = 60.dp) - } - ).padding(bottom = 10.dp) - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp) - ) { - SectionTitle( - title = stringResource(id = R.string.auto_tunneling), - padding = screenPadding - ) - ConfigurationToggle( - stringResource(id = R.string.tunnel_on_wifi), - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - checked = settings.isTunnelOnWifiEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleTunnelOnWifi() - } - }, - modifier = Modifier.focusRequester(focusRequester) - ) - AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) { - Column { - FlowRow( - modifier = Modifier - .padding(screenPadding) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - trustedSSIDs.forEach { ssid -> - ClickableIconButton( - onIconClick = { - scope.launch { - viewModel.onDeleteTrustedSSID(ssid) - } - }, - text = ssid, - icon = Icons.Filled.Close, - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled) - ) - } - if (trustedSSIDs.isEmpty()) { - Text( - stringResource(R.string.none), - fontStyle = FontStyle.Italic, - color = Color.Gray - ) - } - } - OutlinedTextField( - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - value = currentText, - onValueChange = { currentText = it }, - label = { Text(stringResource(R.string.add_trusted_ssid)) }, - modifier = - Modifier - .padding(start = screenPadding, top = 5.dp, bottom = 10.dp) - .onFocusChanged { - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - keyboardController?.hide() - } - }, - 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 = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - checked = settings.isTunnelOnMobileDataEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleTunnelOnMobileData() - } - } - ) - ConfigurationToggle( - stringResource(id = R.string.tunnel_on_ethernet), - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - checked = settings.isTunnelOnEthernetEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleTunnelOnEthernet() - } - } - ) - ConfigurationToggle( - stringResource(R.string.battery_saver), - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - checked = settings.isBatterySaverEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleBatterySaver() - } - } - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - enabled = !settings.isAlwaysOnVpnEnabled, - onClick = { - if (!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) { - val message = - if (!isBackgroundLocationGranted) { - context.getString(R.string.background_location_required) - } else if (viewModel.isLocationServicesNeeded()) { - context.getString(R.string.location_services_required) - } else { - context.getString(R.string.precise_location_required) - } - showSnackbarMessage(message) - } else { - scope.launch { - viewModel.toggleAutoTunnel() - } - } - } - ) { - val autoTunnelButtonText = - if (settings.isAutoTunnelEnabled) { - stringResource(R.string.disable_auto_tunnel) - } else { - stringResource(id = R.string.enable_auto_tunnel) - } - Text(autoTunnelButtonText) - } - } - } - } - if (WgQuickBackend.hasKernelSupport()) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .fillMaxWidth(fillMaxWidth) + Modifier.fillMaxWidth(fillMaxWidth) .padding(vertical = 10.dp) - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp) - ) { + .padding(bottom = 140.dp)) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp)) { SectionTitle( - title = stringResource(id = R.string.kernel), - padding = screenPadding - ) - ConfigurationToggle( - stringResource(R.string.use_kernel), - enabled = !( - settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled || - (vpnState.value == Tunnel.State.UP) - ), - checked = settings.isKernelEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - try { - viewModel.onToggleKernelMode() - } catch (e: WgTunnelException) { - showSnackbarMessage(e.message) - } - } - } - ) - } - } - } - if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - 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 = 140.dp) - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp) - ) { - SectionTitle( - title = stringResource(id = R.string.other), - padding = screenPadding - ) + title = stringResource(id = R.string.other), padding = screenPadding) ConfigurationToggle( stringResource(R.string.always_on_vpn_support), - enabled = !settings.isAutoTunnelEnabled, - checked = settings.isAlwaysOnVpnEnabled, + enabled = !uiState.settings.isAutoTunnelEnabled, + checked = uiState.settings.isAlwaysOnVpnEnabled, padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleAlwaysOnVPN() - } - } - ) + onCheckChanged = { viewModel.onToggleAlwaysOnVPN() }) ConfigurationToggle( stringResource(R.string.enabled_app_shortcuts), enabled = true, - checked = settings.isShortcutsEnabled, + checked = uiState.settings.isShortcutsEnabled, padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleShortcutsEnabled() - } - } - ) + onCheckChanged = { viewModel.onToggleShortcutsEnabled() }) Row( verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center - ) { - TextButton( - enabled = !didExportFiles, - onClick = { - showAuthPrompt = true - } - ) { - Text(stringResource(R.string.export_configs)) + modifier = Modifier.fillMaxSize().padding(top = 5.dp), + horizontalArrangement = Arrangement.Center) { + TextButton( + enabled = !didExportFiles, onClick = { showAuthPrompt = true }) { + Text(stringResource(R.string.export_configs)) + } } - } - } + } } - } - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Spacer(modifier = Modifier.weight(.17f)) - } + } + if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Spacer(modifier = Modifier.weight(.17f)) + } } - } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt new file mode 100644 index 0000000..21f150e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings + +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState + +data class SettingsUiState( + val settings : Settings = Settings(), + val tunnels : List = emptyList(), + val vpnState: VpnState = VpnState(), + val isLocationDisclosureShown : Boolean = true, + val loading : Boolean = true +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt index 959b9f6..6e91616 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -3,195 +3,171 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings import android.app.Application import android.content.Context import android.location.LocationManager -import android.os.Build +import androidx.core.location.LocationManagerCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.android.util.RootShell -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager -import com.zaneschepke.wireguardautotunnel.repository.model.Settings -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager +import com.zaneschepke.wireguardautotunnel.data.model.Settings +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event +import com.zaneschepke.wireguardautotunnel.util.Result import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val application: Application, - private val tunnelRepo: TunnelConfigDao, - private val settingsRepo: SettingsDoa, - val dataStoreManager: DataStoreManager, + private val tunnelConfigRepository: TunnelConfigRepository, + private val settingsRepository: SettingsRepository, + private val dataStoreManager: DataStoreManager, private val rootShell: RootShell, private val vpnService: VpnService ) : ViewModel() { - private val _trustedSSIDs = MutableStateFlow(emptyList()) - val trustedSSIDs = _trustedSSIDs.asStateFlow() - private val _settings = MutableStateFlow(Settings()) - val settings get() = _settings.asStateFlow() - val vpnState get() = vpnService.state - val tunnels get() = tunnelRepo.getAllFlow() - val disclosureShown = dataStoreManager.locationDisclosureFlow + val uiState = combine( + settingsRepository.getSettingsFlow(), + tunnelConfigRepository.getTunnelConfigsFlow(), + vpnService.vpnState, + dataStoreManager.locationDisclosureFlow, + ){ settings, tunnels, tunnelState, locationDisclosure -> + SettingsUiState(settings, tunnels, tunnelState, locationDisclosure + ?: false, false) + }.stateIn(viewModelScope, + SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState()) - init { - isLocationServicesEnabled() - viewModelScope.launch(Dispatchers.IO) { - settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { - val settings = it.first() - _settings.emit(settings) - _trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList()) - } - } - } - suspend fun onSaveTrustedSSID(ssid: String) { + fun onSaveTrustedSSID(ssid: String) : Result{ val trimmed = ssid.trim() - if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) { - _settings.value.trustedNetworkSSIDs.add(trimmed) - settingsRepo.save(_settings.value) + return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) { + uiState.value.settings.trustedNetworkSSIDs.add(trimmed) + saveSettings(uiState.value.settings) + Result.Success(Unit) } else { - throw WgTunnelException("SSID already exists.") + Result.Error(Event.Error.SsidConflict) } } - suspend fun onToggleTunnelOnMobileData() { - settingsRepo.save( - _settings.value.copy( - isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled + fun setLocationDisclosureShown() = viewModelScope.launch { + dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true) + } + + fun onToggleTunnelOnMobileData() { + saveSettings( + uiState.value.settings.copy( + isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled ) ) } - suspend fun onDeleteTrustedSSID(ssid: String) { - _settings.value.trustedNetworkSSIDs.remove(ssid) - settingsRepo.save(_settings.value) + fun onDeleteTrustedSSID(ssid: String) { + saveSettings(uiState.value.settings.copy( + trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList() + )) } - private fun emitFirstTunnelAsDefault() = - viewModelScope.async { - _settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString())) - } + private suspend fun getDefaultTunnelOrFirst() : String { + return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().toString() + } - suspend fun toggleAutoTunnel() { - if (_settings.value.isAutoTunnelEnabled) { + fun toggleAutoTunnel() = viewModelScope.launch { + val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled + var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused + + if (isAutoTunnelEnabled) { ServiceManager.stopWatcherService(application) } else { - if (_settings.value.defaultTunnel == null) { - emitFirstTunnelAsDefault().await() - } - val defaultTunnel = _settings.value.defaultTunnel - ServiceManager.startWatcherService(application, defaultTunnel!!) + ServiceManager.startWatcherService(application) + isAutoTunnelPaused = false } - settingsRepo.save( - _settings.value.copy( - isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled + saveSettings( + uiState.value.settings.copy( + isAutoTunnelEnabled = !isAutoTunnelEnabled, + isAutoTunnelPaused = isAutoTunnelPaused, + defaultTunnel = getDefaultTunnelOrFirst() ) ) } - private suspend fun getFirstTunnelConfig(): TunnelConfig { - return tunnelRepo.getAll().first() - } - suspend fun onToggleAlwaysOnVPN() { - if (_settings.value.defaultTunnel == null) { - emitFirstTunnelAsDefault().await() - } - val updatedSettings = - _settings.value.copy( - isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled + fun onToggleAlwaysOnVPN() = viewModelScope.launch { + val updatedSettings = uiState.value.settings.copy( + isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, + defaultTunnel = getDefaultTunnelOrFirst() ) - emitSettings(updatedSettings) saveSettings(updatedSettings) } - private suspend fun emitSettings(settings: Settings) { - _settings.emit( - settings - ) + private fun saveSettings(settings: Settings) = viewModelScope.launch { + settingsRepository.save(settings) } - private suspend fun saveSettings(settings: Settings) { - settingsRepo.save(settings) + fun onToggleTunnelOnEthernet() { + saveSettings(uiState.value.settings.copy( + isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled + )) } - suspend fun onToggleTunnelOnEthernet() { - if (_settings.value.defaultTunnel == null) { - emitFirstTunnelAsDefault().await() - } - _settings.emit( - _settings.value.copy( - isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled - ) - ) - settingsRepo.save(_settings.value) + fun isLocationEnabled(context: Context): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return LocationManagerCompat.isLocationEnabled(locationManager) } - private fun isLocationServicesEnabled(): Boolean { - val locationManager = - application.getSystemService(Context.LOCATION_SERVICE) as LocationManager - return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) - } - - fun isLocationServicesNeeded(): Boolean { - return (!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) - } - - suspend fun onToggleShortcutsEnabled() { - settingsRepo.save( - _settings.value.copy( - isShortcutsEnabled = !_settings.value.isShortcutsEnabled + fun onToggleShortcutsEnabled() { + saveSettings( + uiState.value.settings.copy( + isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled ) ) } - suspend fun onToggleBatterySaver() { - settingsRepo.save( - _settings.value.copy( - isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled + fun onToggleBatterySaver() { + saveSettings( + uiState.value.settings.copy( + isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled ) ) } - private suspend fun saveKernelMode(on: Boolean) { - settingsRepo.save( - _settings.value.copy( + private fun saveKernelMode(on: Boolean) { + saveSettings( + uiState.value.settings.copy( isKernelEnabled = on ) ) } - suspend fun onToggleKernelMode() { - if (!_settings.value.isKernelEnabled) { + fun onToggleTunnelOnWifi() { + saveSettings( + uiState.value.settings.copy( + isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled + ) + ) + } + + fun onToggleKernelMode() : Result { + if (!uiState.value.settings.isKernelEnabled) { try { rootShell.start() Timber.d("Root shell accepted!") saveKernelMode(on = true) } catch (e: RootShell.RootShellException) { saveKernelMode(on = false) - throw WgTunnelException("Root shell denied!") + return Result.Error(Event.Error.RootDenied) } } else { saveKernelMode(on = false) } - } - - suspend fun onToggleTunnelOnWifi() { - settingsRepo.save( - _settings.value.copy( - isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled - ) - ) + return Result.Success(Unit) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt index 9ac9c64..518211d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle +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 @@ -48,197 +49,180 @@ import androidx.core.content.ContextCompat.startActivity import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.BuildConfig -import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsViewModel +import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.Event @Composable fun SupportScreen( - viewModel: SettingsViewModel = hiltViewModel(), + viewModel: SupportViewModel = hiltViewModel(), padding: PaddingValues, + showSnackbarMessage: (String) -> Unit, focusRequester: FocusRequester ) { - val context = LocalContext.current - val fillMaxWidth = .85f + val context = LocalContext.current + val fillMaxWidth = .85f - val settings by viewModel.settings.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() - fun openWebPage(url: String) { - val webpage: Uri = Uri.parse(url) - val intent = Intent(Intent.ACTION_VIEW, webpage) - context.startActivity(intent) - } + fun openWebPage(url: String) { + try { + val webpage: Uri = Uri.parse(url) + val intent = Intent(Intent.ACTION_VIEW, webpage) + context.startActivity(intent) + } catch (e : Exception) { + showSnackbarMessage(Event.Error.Exception(e).message) + } + } - fun launchEmail() { - val intent = - Intent(Intent.ACTION_SEND).apply { - type = Constants.EMAIL_MIME_TYPE - putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) - putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) - } - startActivity( - context, - createChooser(intent, context.getString(R.string.email_chooser)), - null - ) - } + fun launchEmail() { + try { + val intent = + Intent(Intent.ACTION_SEND).apply { + type = Constants.EMAIL_MIME_TYPE + putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) + putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) + } + startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null) + } catch (e : Exception) { + showSnackbarMessage(Event.Error.Exception(e).message) + } + } + + if (uiState.loading) { + LoadingScreen() + return + } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .focusable() - .padding(padding) - ) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - ( - if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { - Modifier - .height(IntrinsicSize.Min) + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .focusable() + .padding(padding)) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { + Modifier.height(IntrinsicSize.Min) .fillMaxWidth(fillMaxWidth) .padding(top = 10.dp) - } else { - Modifier - .fillMaxWidth(fillMaxWidth) - .padding(top = 20.dp) - } - ).padding(bottom = 25.dp) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Text( - stringResource(R.string.thank_you), - textAlign = TextAlign.Start, - 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 = { - openWebPage(context.resources.getString(R.string.docs_url)) - }, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Row { - Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) - Text( - stringResource(id = R.string.docs_description), - textAlign = TextAlign.Justify, - modifier = Modifier.padding(start = 10.dp) - ) - } - Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) - } + } else { + Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp) + }) + .padding(bottom = 25.dp)) { + Column(modifier = Modifier.padding(20.dp)) { + 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 = { openWebPage(context.resources.getString(R.string.docs_url)) }, + modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + Row { + Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) + Text( + stringResource(id = R.string.docs_description), + textAlign = TextAlign.Justify, + modifier = Modifier.padding(start = 10.dp)) + } + Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) + } + } + Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) + TextButton( + onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, + modifier = Modifier.padding(vertical = 5.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + Row { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.discord), + stringResource(id = R.string.discord), + Modifier.size(25.dp)) + Text( + stringResource(id = R.string.discord_description), + textAlign = TextAlign.Justify, + modifier = Modifier.padding(start = 10.dp)) + } + Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) + } + } + Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) + TextButton( + onClick = { openWebPage(context.resources.getString(R.string.github_url)) }, + modifier = Modifier.padding(vertical = 5.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + Row { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.github), + stringResource(id = R.string.github), + Modifier.size(25.dp)) + Text( + "Open an issue", + textAlign = TextAlign.Justify, + modifier = Modifier.padding(start = 10.dp)) + } + Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) + } + } + Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) + TextButton( + onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth()) { + Row { + Icon(Icons.Rounded.Mail, stringResource(id = R.string.email)) + Text( + stringResource(id = R.string.email_description), + textAlign = TextAlign.Justify, + modifier = Modifier.padding(start = 10.dp)) + } + Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) + } + } } - Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) - TextButton( - onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, - modifier = Modifier.padding(vertical = 5.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Row { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.discord), - stringResource( - id = R.string.discord - ), - Modifier.size(25.dp) - ) - Text( - stringResource(id = R.string.discord_description), - textAlign = TextAlign.Justify, - modifier = Modifier.padding(start = 10.dp) - ) - } - Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) - } - } - Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) - TextButton( - onClick = { openWebPage(context.resources.getString(R.string.github_url)) }, - modifier = Modifier.padding(vertical = 5.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Row { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.github), - stringResource( - id = R.string.github - ), - Modifier.size(25.dp) - ) - Text( - "Open an issue", - textAlign = TextAlign.Justify, - modifier = Modifier.padding(start = 10.dp) - ) - } - Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) - } - } - Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) - TextButton( - onClick = { launchEmail() }, - modifier = Modifier.padding(vertical = 5.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Row { - Icon(Icons.Rounded.Mail, stringResource(id = R.string.email)) - Text( - stringResource(id = R.string.email_description), - textAlign = TextAlign.Justify, - modifier = Modifier.padding(start = 10.dp) - ) - } - Icon(Icons.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 { + openWebPage(context.resources.getString(R.string.privacy_policy_url)) + }) + Row( + horizontalArrangement = Arrangement.spacedBy(25.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(25.dp)) { + Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable()) + Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace" }") + } } - Spacer(modifier = Modifier.weight(1f)) - Text( - stringResource(id = R.string.privacy_policy), - style = TextStyle(textDecoration = TextDecoration.Underline), - fontSize = 16.sp, - modifier = - Modifier.clickable { - openWebPage(context.resources.getString(R.string.privacy_policy_url)) - } - ) - Row( - horizontalArrangement = Arrangement.spacedBy(25.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(25.dp) - ) { - Text("Version: ${BuildConfig.VERSION_NAME}") - Text("Mode: ${if (settings.isKernelEnabled) "Kernel" else "Userspace" }") - } - } -} + } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt new file mode 100644 index 0000000..ef5dcc8 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt @@ -0,0 +1,8 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.support + +import com.zaneschepke.wireguardautotunnel.data.model.Settings + +data class SupportUiState( + val settings : Settings = Settings(), + val loading : Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt index b58ac11..f023ad9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt @@ -2,24 +2,24 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa -import com.zaneschepke.wireguardautotunnel.repository.model.Settings +import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository +import com.zaneschepke.wireguardautotunnel.util.Constants import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch @HiltViewModel class SupportViewModel @Inject constructor( - private val settingsRepo: SettingsDoa + private val settingsRepository: SettingsRepository ) : ViewModel() { - private val _settings = MutableStateFlow(Settings()) - val settings get() = _settings.asStateFlow() - init { - viewModelScope.launch(Dispatchers.IO) { - _settings.value = settingsRepo.getAll().first() - } - } + + val uiState = settingsRepository.getSettingsFlow().map { + SupportUiState(it, false) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), + SupportUiState() + ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt similarity index 60% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt index 8f55662..0debacc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt @@ -1,14 +1,12 @@ -package com.zaneschepke.wireguardautotunnel +package com.zaneschepke.wireguardautotunnel.util object Constants { const val MANUAL_TUNNEL_CONFIG_ID = "0" - const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes - const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L + const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes + const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1000L // 30 minutes const val VPN_STATISTIC_CHECK_INTERVAL = 1000L - const val TOGGLE_TUNNEL_DELAY = 500L - const val FADE_IN_ANIMATION_DURATION = 1000 - const val SLIDE_IN_ANIMATION_DURATION = 500 - const val SLIDE_IN_TRANSITION_OFFSET = 1000 + const val VPN_CONNECTED_NOTIFICATION_DELAY = 3000L + const val TOGGLE_TUNNEL_DELAY = 300L const val CONF_FILE_EXTENSION = ".conf" const val ZIP_FILE_EXTENSION = ".zip" const val URI_CONTENT_SCHEME = "content" @@ -18,4 +16,7 @@ object Constants { const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val EMAIL_MIME_TYPE = "message/rfc822" const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 + + const val SUBSCRIPTION_TIMEOUT = 5_000L + const val FOCUS_REQUEST_DELAY = 500L } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Event.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Event.kt new file mode 100644 index 0000000..a7da4b0 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Event.kt @@ -0,0 +1,90 @@ +package com.zaneschepke.wireguardautotunnel.util + +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel + +sealed class Event { + + abstract val message: String + + sealed class Error : Event() { + data object None : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_none) + } + data object SsidConflict : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists) + } + data object RootDenied : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied) + } + data class General(val customMessage: String) : Error() { + override val message: String + get() = customMessage + } + data class Exception(val exception : kotlin.Exception) : Error() { + override val message: String + get() = exception.message ?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error) + } + data object InvalidQrCode : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code) + } + data object InvalidFileExtension : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) + } + data object FileReadFailed : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) + } + data object AuthenticationFailed : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed) + } + data object AuthorizationFailed : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed) + } + data object BackgroundLocationRequired : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.background_location_required) + } + data object LocationServicesRequired : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required) + } + data object PreciseLocationRequired : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required) + } + data object FileExplorerRequired : Error() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer) + } + } + sealed class Message : Event() { + data object ConfigSaved: Message() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved) + } + data object ConfigsExported: Message() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message) + } + data object TunnelOffAction: Message() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel) + } + data object TunnelOnAction: Message() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel) + } + data object AutoTunnelOffAction: Message() { + override val message: String + get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Extensions.kt new file mode 100644 index 0000000..7eb0586 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Extensions.kt @@ -0,0 +1,68 @@ +package com.zaneschepke.wireguardautotunnel.util + +import android.content.BroadcastReceiver +import android.content.pm.PackageInfo +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Statistics.PeerStats +import com.wireguard.crypto.Key +import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.text.DecimalFormat +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +fun BroadcastReceiver.goAsync( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit +) { + val pendingResult = goAsync() + @OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback. + GlobalScope.launch(context) { + try { + block() + } finally { + pendingResult.finish() + } + } +} + +fun BigDecimal.toThreeDecimalPlaceString(): String { + val df = DecimalFormat("#.###") + return df.format(this) +} + +fun List.update(index: Int, item: T): List = toMutableList().apply { this[index] = item } +fun List.removeAt(index: Int): List = toMutableList().apply { this.removeAt(index) } + +typealias TunnelConfigs = List +typealias Packages = List + +fun Statistics.mapPeerStats(): Map { + return this.peers().associateWith { key -> + (this.peer(key)) + } +} + +fun PeerStats.latestHandshakeSeconds() : Long? { + return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis) +} + +fun PeerStats.handshakeStatus() : HandshakeStatus { + //TODO add never connected status after duration + return this.latestHandshakeSeconds().let { + when { + it == null -> HandshakeStatus.NOT_STARTED + it <= HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.HEALTHY + it > HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.STALE + else -> { + HandshakeStatus.UNKNOWN + } + } + } +} + diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt index b86e565..2651dab 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt @@ -6,7 +6,6 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.MediaStore.MediaColumns -import com.zaneschepke.wireguardautotunnel.Constants import java.io.File import java.io.OutputStream import java.time.Instant diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Result.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Result.kt new file mode 100644 index 0000000..c5d5913 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Result.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.wireguardautotunnel.util + +import timber.log.Timber + + +sealed class Result { + class Success(val data: T): Result() + class Error(val error : Event.Error): Result() { + init { + when(this.error) { + is Event.Error.Exception -> Timber.e(this.error.exception) + else -> Timber.e(this.error.message) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt deleted file mode 100644 index 544bf5a..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.util - -import com.wireguard.config.BadConfigException - -class WgTunnelException(e: Exception) : Exception() { - constructor(message: String) : this(Exception(message)) - - override val message: String = generateExceptionMessage(e) - - private fun generateExceptionMessage(e: Exception): String { - return when (e) { - is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}" - else -> e.message ?: "Unknown error occurred" - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80f453e..7a8b6e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,13 +9,14 @@ https://github.com/zaneschepke/wgtunnel/issues https://zaneschepke.com/wgtunnel-docs/overview.html https://zaneschepke.com/wgtunnel-docs/privacypolicy.html - File is not a .conf or .zip - Turn off tunnel before editing + File is not a .conf or .zip + Action requires tunnel off No tunnels added yet! Tunnel name already exists https://discord.gg/rbRRNh6H7V Watcher Service - Monitoring network state changes + Monitoring network state changes: active + Monitoring network state changes: paused VPN Connected Connected to tunnel - VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again. @@ -78,7 +79,7 @@ Attempting to reconnect to server after more than one minute of no response. Allow Always-On VPN Please select a tunnel first - Unable to detect Location Services which are required for this feature. Please enable Location Services. + Location Services Not Detected Check again Detecting Location Services disabled This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings. @@ -96,8 +97,6 @@ No trusted wifi names Never Failed to open file stream. - An unknown error occurred. - No file app installed. Other Auto-tunneling Select tunnel to use @@ -108,6 +107,7 @@ Create from scratch Set primary Action requires auto-tunnel disabled + Action requires active tunnel Add peer Info Done @@ -128,7 +128,8 @@ Cancel Primary tunnel change Would you like to make this your primary tunnel? - Authentication failed + Authentication failed + Failed to authorize Enable app shortcuts Export configs Battery saver (beta) @@ -137,7 +138,6 @@ Precise location required Unknown error occurred Exported configs to downloads - No file explorer installed status Tunnel on untrusted wifi zanecschepke@gmail.com @@ -154,4 +154,14 @@ If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available: Kernel Use kernel module + SSID already exists + Root shell denied + No file explorer installed + No code scanned + Invalid QR code + No error + Failed to read file + Location Services Not Detected + The app is not detecting any location services enabled on your device. Depending on the device, this could cause the untrusted wifi feature to fail to read the wifi name. Would you like to continue anyways? + Auto-tunnel Service \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt index 17ec898..d108ec8 100644 --- a/buildSrc/src/main/kotlin/Constants.kt +++ b/buildSrc/src/main/kotlin/Constants.kt @@ -1,8 +1,9 @@ object Constants { - const val VERSION_NAME = "3.2.5" + const val VERSION_NAME = "3.3.0" const val JVM_TARGET = "17" - const val VERSION_CODE = 32500 - const val TARGET_SDK = 34 + const val VERSION_CODE = 33000 + const val TARGET_SDK = 28 + const val COMPILE_SDK = 34 const val MIN_SDK = 26 const val APP_ID = "com.zaneschepke.wireguardautotunnel" const val APP_NAME = "wgtunnel" diff --git a/fastlane/metadata/android/en-US/changelogs/33000.txt b/fastlane/metadata/android/en-US/changelogs/33000.txt new file mode 100644 index 0000000..c75588b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33000.txt @@ -0,0 +1,8 @@ +Enhancements: +- Refactor state management +- Improve AndroidTV navigation +- Improve auto-tunneling efficiency +- Improve navigation +- Auto-tunneling pause feature +- Many bugfixes +