From fa4cc84c0e8e8065cea77f7b83d0e9734dc59705 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Mon, 1 Sep 2025 16:08:40 -0400 Subject: [PATCH] refactor: navigation and viewmodels (#925) - Added UI support for Amnezia 1.5 - Fix for http proxy race - Fix for db migration race - Split tunnel app packages caching - Detailed config parsing error messages - Added navgraph with viewmodel scoping - Pin minor ui enhancements --- app/build.gradle.kts | 6 + app/proguard-rules.pro | 3 + .../22.json | 364 +++++++ .../wireguardautotunnel/MainActivity.kt | 471 ++++----- .../WireGuardAutoTunnel.kt | 21 +- .../core/notification/NotificationManager.kt | 8 +- .../core/service/TunnelForegroundService.kt | 1 + .../service/autotunnel/AutoTunnelService.kt | 10 +- .../core/service/autotunnel/StateChange.kt | 4 +- .../core/tunnel/TunnelManager.kt | 6 +- .../core/tunnel/UserspaceTunnel.kt | 1 - .../wireguardautotunnel/data/AppDatabase.kt | 3 +- .../data/DataStoreManager.kt | 5 - .../data/DatabaseCallback.kt | 14 +- .../data/DatabaseConverters.kt | 10 + .../data/dao/TunnelConfigDao.kt | 2 + .../data/entity/ProxySettings.kt | 4 +- .../data/entity/Settings.kt | 36 +- .../data/entity/TunnelConfig.kt | 4 +- .../data/mapper/SettingsMapper.kt | 16 +- .../data/model/WifiDetectionMethod.kt | 4 + .../data/repository/AppDataRoomRepository.kt | 2 +- .../repository/DataStoreAppStateRepository.kt | 19 +- .../InstalledAndroidPackageRepository.kt | 92 ++ .../data/repository/RoomSettingsRepository.kt | 12 +- .../data/repository/RoomTunnelRepository.kt | 6 + .../wireguardautotunnel/di/AppModule.kt | 1 + .../di/RepositoryModule.kt | 30 +- .../wireguardautotunnel/di/TunnelModule.kt | 7 +- .../domain/model/AppState.kt | 20 +- .../{AppSettings.kt => GeneralSettings.kt} | 18 +- .../domain/model/InstalledPackage.kt | 3 + .../domain/model/TunnelConf.kt | 21 +- .../domain/repository/AppDataRepository.kt | 2 +- .../domain/repository/AppSettingRepository.kt | 12 - .../domain/repository/EffectRepository.kt | 18 + .../repository/GeneralSettingRepository.kt | 12 + .../repository/InstalledPackageRepository.kt | 12 + .../domain/repository/TunnelRepository.kt | 2 + .../domain/sideeffect/GlobalSideEffect.kt | 27 + .../domain/state/AutoTunnelState.kt | 6 +- .../wireguardautotunnel/ui/Locals.kt | 13 + .../ui/common/ExpandingRowListItem.kt | 2 +- .../ui/common/button/ActionIconButton.kt | 21 + .../ui/common/button/IconSurfaceButton.kt | 4 +- .../ui/common/button/SelectionItemButton.kt | 50 +- .../button/surface/SelectionItemLabel.kt | 1 + .../ui/common/functions/Functions.kt | 16 +- .../ui/common/snackbar/CustomSnackbar.kt | 2 +- .../ui/navigation/BottomNavItem.kt | 3 +- .../ui/navigation/Extensions.kt | 20 - .../ui/{ => navigation}/Route.kt | 66 +- .../ui/navigation/components/BottomNavbar.kt | 170 ++-- .../navigation/components/DynamicTopAppBar.kt | 21 +- .../currentNavBackStackEntryAsNavBarState.kt | 335 ------- .../ui/screens/autotunnel/AutoTunnelScreen.kt | 74 +- .../advanced/AutoTunnelAdvancedScreen.kt | 29 +- .../components/NetworkTunnelingItems.kt | 52 +- .../components/TrustNetworksTextBox.kt | 4 +- .../components/wifiTunnelingItems.kt | 101 +- .../detection/WifiDetectionMethodScreen.kt | 35 +- .../disclosure/LocationDisclosureScreen.kt | 16 +- .../disclosure/components/AppSettingsItem.kt | 10 +- .../components/LocationDisclosureHeader.kt | 6 +- .../disclosure/components/SkipItem.kt | 21 +- .../ui/screens/main/MainScreen.kt | 140 --- .../main/autotunnel/TunnelAutoTunnelScreen.kt | 57 -- .../ui/screens/main/config/ConfigScreen.kt | 128 --- .../ui/screens/main/config/ConfigViewModel.kt | 119 --- .../config/components/InterfaceSection.kt | 74 -- .../main/config/state/ConfigUiState.kt | 16 - .../main/splittunnel/SplitTunnelScreen.kt | 52 - .../main/splittunnel/SplitTunnelViewModel.kt | 180 ---- .../splittunnel/components/AppListItem.kt | 50 - .../splittunnel/state/SplitTunnelUiState.kt | 13 - .../main/tunneloptions/TunnelOptionsScreen.kt | 85 -- .../ui/screens/pin/PinLockScreen.kt | 57 +- .../ui/screens/settings/SettingsScreen.kt | 121 ++- .../settings/appearance/AppearanceScreen.kt | 22 +- .../appearance/components/DisplayItem.kt | 7 +- .../appearance/components/LanguageItem.kt | 7 +- .../appearance/display/DisplayScreen.kt | 29 +- .../appearance/language/LanguageScreen.kt | 70 +- .../components/AutomaticLanguageItem.kt | 19 +- .../language/components/LanguageItem.kt | 18 +- .../components/AuthorizationPromptWrapper.kt | 25 +- .../settings/components/BackupBottomSheet.kt | 11 +- .../settings/components/LanTrafficItem.kt | 17 +- .../settings/components/LocalLoggingItem.kt | 17 +- .../settings/components/PinLockItem.kt | 29 +- .../settings/components/appearanceItem.kt | 7 +- .../settings/components/backendModeItem.kt | 16 +- .../settings/components/dnsSettingsItem.kt | 7 +- .../settings/components/proxySettingsItem.kt | 7 +- .../{ReadLogsItem.kt => readLogsItem.kt} | 7 +- .../settings/components/systemFeaturesItem.kt | 7 +- .../components/tunnelMonitoringItem.kt | 7 +- .../screens/settings/dns/DnsSettingsScreen.kt | 37 +- .../ui/screens/settings/logs/LogsScreen.kt | 52 +- .../logs/components/LogsBottomSheet.kt | 17 +- .../monitoring/TunnelMonitoringScreen.kt | 66 +- .../components/detailedPingStatsItem.kt | 17 +- .../components/enablePingMonitoringItem.kt | 17 +- .../settings/proxy/ProxySettingsScreen.kt | 169 ++-- ...deBottomSheet.kt => AppModeBottomSheet.kt} | 18 +- .../settings/system/SystemFeaturesScreen.kt | 51 +- .../system/components/alwaysOnVpnItem.kt | 21 +- .../system/components/appShortcutItem.kt | 14 +- .../system/components/remoteControlItem.kt | 22 +- .../system/components/restartAtBootItem.kt | 14 +- .../ui/screens/support/SupportScreen.kt | 78 +- .../ui/screens/support/SupportViewModel.kt | 94 -- .../components/GeneralSupportOptions.kt | 9 +- .../screens/support/license/LicenseScreen.kt | 15 + .../ui/screens/tunnels/TunnelsScreen.kt | 205 ++++ .../autotunnel/TunnelAutoTunnelScreen.kt | 80 ++ .../components/MobileDataTunnelItem.kt | 16 +- .../autotunnel/components/PingRestartItem.kt | 16 +- .../autotunnel/components/WifiTunnelItem.kt | 36 +- .../components/ethernetTunnelItem.kt | 16 +- .../components/ExportTunnelsBottomSheet.kt | 32 +- .../components/GettingStartedLabel.kt | 2 +- .../components/ScrollDismissMultiFab.kt | 2 +- .../components/TunnelImportSheet.kt | 4 +- .../components/TunnelList.kt | 68 +- .../components/TunnelRowItem.kt | 12 +- .../components/TunnelStatisticsRow.kt | 2 +- .../components/UrlImportDialog.kt | 2 +- .../ui/screens/tunnels/config/ConfigScreen.kt | 150 +++ .../config/components/AddPeerButton.kt | 7 +- .../config/components/InterfaceDropdown.kt | 31 +- .../config/components/InterfaceFields.kt | 146 ++- .../config/components/InterfaceSection.kt | 107 ++ .../config/components/PeerFields.kt | 2 +- .../config/components/PeersSection.kt | 27 +- .../{main => tunnels}/sort/SortScreen.kt | 96 +- .../tunnels/splittunnel/SplitTunnelScreen.kt | 80 ++ .../splittunnel/components/AppListItem.kt | 60 ++ .../splittunnel/components/AppListSection.kt | 46 +- .../components/SplitOptionsSelector.kt | 4 +- .../components/SplitTunnelContent.kt | 21 +- .../splittunnel/state/SplitOption.kt | 2 +- .../splittunnel/state/SplitTunnelUiState.kt | 11 + .../splittunnel/state/TunnelApp.kt | 2 +- .../tunneloptions/TunnelOptionsScreen.kt | 116 +++ .../components/AutoTunnelingItem.kt | 9 +- .../components/PrimaryTunnelItem.kt | 15 +- .../tunneloptions/components/QrCodeDialog.kt | 2 +- .../components/SplitTunnelingItem.kt | 9 +- .../components/pingConfigItem.kt | 10 +- .../components/serverIpv4Item.kt | 15 +- .../ui/state/AppUiState.kt | 22 - .../ui/state/AppViewState.kt | 32 - .../ui/state/AutoTunnelUiState.kt | 12 + .../ui/state/ConfigProxy.kt | 20 +- .../ui/state/InterfaceProxy.kt | 372 +++++-- .../ui/state/LoggerUiState.kt | 5 + .../ui/state/NavBarState.kt | 12 - .../ui/state/NavbarState.kt | 13 + .../ui/state/ProxySettingsUiState.kt | 13 + .../ui/state/SettingUiState.kt | 13 + .../ui/state/SharedAppUiState.kt | 17 + .../ui/state/SplitTunnelUiState.kt | 10 + .../support => state}/SupportUiState.kt | 5 +- .../ui/state/TunnelsUiState.kt | 16 + .../wireguardautotunnel/ui/theme/Theme.kt | 2 +- .../wireguardautotunnel/util/Constants.kt | 13 - .../wireguardautotunnel/util/FileUtils.kt | 287 +++--- .../util/extensions/ContextExtensions.kt | 50 +- .../util/extensions/CoroutineExtensions.kt | 30 +- .../util/extensions/Extensions.kt | 8 +- .../util/extensions/RepositoryExtensions.kt | 43 + .../util/extensions/StringExtensions.kt | 19 +- .../util/extensions/TunnelExtensions.kt | 39 + .../util/extensions/UiExtensions.kt | 61 +- .../viewmodel/AppViewModel.kt | 925 ------------------ .../viewmodel/AutoTunnelViewModel.kt | 125 +++ .../viewmodel/LoggerViewModel.kt | 97 ++ .../viewmodel/ProxySettingsViewModel.kt | 102 ++ .../viewmodel/SettingsViewModel.kt | 107 ++ .../viewmodel/SharedAppViewModel.kt | 196 ++++ .../viewmodel/SplitTunnelViewModel.kt | 85 ++ .../viewmodel/SupportViewModel.kt | 116 +++ .../viewmodel/TunnelsViewModel.kt | 330 +++++++ .../viewmodel/event/AppEvent.kt | 160 --- .../viewmodel/event/UiEvent.kt | 5 - .../main/res/drawable/resource_package.xml | 9 + app/src/main/res/values/strings.xml | 21 +- gradle/libs.versions.toml | 11 +- 189 files changed, 5106 insertions(+), 4277 deletions(-) create mode 100644 app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/{AppSettings.kt => GeneralSettings.kt} (77%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/InstalledPackage.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppSettingRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/EffectRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/InstalledPackageRepository.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Locals.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ActionIconButton.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Extensions.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/{ => navigation}/Route.kt (66%) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/TunnelAutoTunnelScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/TunnelOptionsScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/{ReadLogsItem.kt => readLogsItem.kt} (83%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/{BackendModeBottomSheet.kt => AppModeBottomSheet.kt} (56%) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/autotunnel/components/MobileDataTunnelItem.kt (62%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/autotunnel/components/PingRestartItem.kt (56%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/autotunnel/components/WifiTunnelItem.kt (66%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/autotunnel/components/ethernetTunnelItem.kt (62%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/ExportTunnelsBottomSheet.kt (74%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/GettingStartedLabel.kt (96%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/ScrollDismissMultiFab.kt (93%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/TunnelImportSheet.kt (94%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/TunnelList.kt (55%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/TunnelRowItem.kt (92%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/TunnelStatisticsRow.kt (99%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/components/UrlImportDialog.kt (95%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/config/components/AddPeerButton.kt (70%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/config/components/InterfaceDropdown.kt (71%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/config/components/InterfaceFields.kt (61%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/config/components/PeerFields.kt (97%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/config/components/PeersSection.kt (83%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/sort/SortScreen.kt (63%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/splittunnel/components/AppListSection.kt (60%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/splittunnel/components/SplitOptionsSelector.kt (94%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/splittunnel/components/SplitTunnelContent.kt (59%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/splittunnel/state/SplitOption.kt (91%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/splittunnel/state/TunnelApp.kt (60%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/AutoTunnelingItem.kt (81%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/PrimaryTunnelItem.kt (65%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/QrCodeDialog.kt (98%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/SplitTunnelingItem.kt (78%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/pingConfigItem.kt (76%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{main => tunnels}/tunneloptions/components/serverIpv4Item.kt (65%) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppUiState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppViewState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LoggerUiState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavBarState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavbarState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SettingUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/{screens/support => state}/SupportUiState.kt (51%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/RepositoryExtensions.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SupportViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt create mode 100644 app/src/main/res/drawable/resource_package.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cebd674f..230ddaff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.grgit) alias(libs.plugins.licensee) + id("kotlin-parcelize") } android { @@ -229,6 +230,11 @@ dependencies { implementation(libs.roomdatabasebackup) { exclude(group = "org.reactivestreams", module = "reactive-streams") } + + // state management + implementation(libs.orbit.compose) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.core) } tasks.register("copyLicenseeJsonToAssets") { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e69de29b..d06e655d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep class com.zaneschepke.wireguardautotunnel.ui.navigation.Route { *; } +-keep class com.zaneschepke.wireguardautotunnel.ui.navigation.Route$** { *; } +-keepclassmembers class com.zaneschepke.wireguardautotunnel.ui.navigation.Route$** { *; } \ No newline at end of file diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json new file mode 100644 index 00000000..21558610 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/22.json @@ -0,0 +1,364 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "db93d0490401ccbef25ca39f27bafa29", + "entities": [ + { + "tableName": "Settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_ping_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT 0, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_disable_kill_switch_on_trusted_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER, `app_mode` INTEGER NOT NULL DEFAULT 0, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAutoTunnelEnabled", + "columnName": "is_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnMobileDataEnabled", + "columnName": "is_tunnel_on_mobile_data_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "trustedNetworkSSIDs", + "columnName": "trusted_network_ssids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isAlwaysOnVpnEnabled", + "columnName": "is_always_on_vpn_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnEthernetEnabled", + "columnName": "is_tunnel_on_ethernet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShortcutsEnabled", + "columnName": "is_shortcuts_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnWifiEnabled", + "columnName": "is_tunnel_on_wifi_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isRestoreOnBootEnabled", + "columnName": "is_restore_on_boot_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMultiTunnelEnabled", + "columnName": "is_multi_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPingEnabled", + "columnName": "is_ping_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isWildcardsEnabled", + "columnName": "is_wildcards_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isStopOnNoInternetEnabled", + "columnName": "is_stop_on_no_internet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isLanOnKillSwitchEnabled", + "columnName": "is_lan_on_kill_switch_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "debounceDelaySeconds", + "columnName": "debounce_delay_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "isDisableKillSwitchOnTrustedEnabled", + "columnName": "is_disable_kill_switch_on_trusted_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnUnsecureEnabled", + "columnName": "is_tunnel_on_unsecure_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wifiDetectionMethod", + "columnName": "wifi_detection_method", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPingMonitoringEnabled", + "columnName": "is_ping_monitoring_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "tunnelPingIntervalSeconds", + "columnName": "tunnel_ping_interval_sec", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "tunnelPingAttempts", + "columnName": "tunnel_ping_attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "tunnelPingTimeoutSeconds", + "columnName": "tunnel_ping_timeout_sec", + "affinity": "INTEGER" + }, + { + "fieldPath": "appMode", + "columnName": "app_mode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dnsProtocol", + "columnName": "dns_protocol", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dnsEndpoint", + "columnName": "dns_endpoint", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TunnelConfig", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `restart_on_ping_failure` INTEGER NOT NULL DEFAULT false, `ping_target` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wgQuick", + "columnName": "wg_quick", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tunnelNetworks", + "columnName": "tunnel_networks", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isMobileDataTunnel", + "columnName": "is_mobile_data_tunnel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isPrimaryTunnel", + "columnName": "is_primary_tunnel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "amQuick", + "columnName": "am_quick", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isActive", + "columnName": "is_Active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "restartOnPingFailure", + "columnName": "restart_on_ping_failure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "pingTarget", + "columnName": "ping_target", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "isEthernetTunnel", + "columnName": "is_ethernet_tunnel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isIpv4Preferred", + "columnName": "is_ipv4_preferred", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "autoTunnelApps", + "columnName": "auto_tunnel_apps", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + } + ], + "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`)" + } + ] + }, + { + "tableName": "proxy_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socks5ProxyEnabled", + "columnName": "socks5_proxy_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "socks5ProxyBindAddress", + "columnName": "socks5_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "httpProxyEnabled", + "columnName": "http_proxy_enable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "httpProxyBindAddress", + "columnName": "http_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyUsername", + "columnName": "proxy_username", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyPassword", + "columnName": "proxy_password", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, 'db93d0490401ccbef25ca39f27bafa29')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt index c7e86d68..ce8c2058 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -1,5 +1,6 @@ package com.zaneschepke.wireguardautotunnel +import ProxySettingsScreen import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color @@ -15,16 +16,12 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -34,35 +31,29 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navigation import androidx.navigation.toRoute import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.data.AppDatabase import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.di.MainDispatcher +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository -import com.zaneschepke.wireguardautotunnel.ui.Route +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.navigation.components.BottomNavbar import com.zaneschepke.wireguardautotunnel.ui.navigation.components.DynamicTopAppBar -import com.zaneschepke.wireguardautotunnel.ui.navigation.components.currentNavBackStackEntryAsNavBarState import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.AutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.AutoTunnelAdvancedScreen import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.detection.WifiDetectionMethodScreen import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.LocationDisclosureScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.sort.SortScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen @@ -71,47 +62,38 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.langua import com.zaneschepke.wireguardautotunnel.ui.screens.settings.dns.DnsSettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.TunnelMonitoringScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.ProxySettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.system.SystemFeaturesScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.LicenseScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.TunnelsScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.TunnelAutoTunnelScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.ConfigScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.sort.SortScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.SplitTunnelScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.TunnelOptionsScreen import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed import com.zaneschepke.wireguardautotunnel.ui.theme.OffWhite import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme -import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv -import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp -import com.zaneschepke.wireguardautotunnel.util.extensions.showToast -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.util.LocaleUtil +import com.zaneschepke.wireguardautotunnel.util.extensions.* +import com.zaneschepke.wireguardautotunnel.viewmodel.* import dagger.hilt.android.AndroidEntryPoint import de.raphaelebner.roomdatabasebackup.core.RoomBackup -import java.util.Locale +import java.util.* import javax.inject.Inject -import kotlin.system.exitProcess -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch +import xyz.teamgravity.pin_lock_compose.PinManager @AndroidEntryPoint class MainActivity : AppCompatActivity() { @Inject lateinit var appStateRepository: AppStateRepository - @Inject lateinit var tunnelManager: TunnelManager - @Inject lateinit var networkMonitor: NetworkMonitor - - @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher - - @Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher - @Inject lateinit var appDatabase: AppDatabase - private var lastLocationPermissionState: Boolean? = null - private lateinit var roomBackup: RoomBackup - val REQUEST_CODE = 123 - @SuppressLint("BatteryLife") override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge( @@ -122,32 +104,40 @@ class MainActivity : AppCompatActivity() { window.isNavigationBarContrastEnforced = false } super.onCreate(savedInstanceState) + roomBackup = RoomBackup(this) - val viewModel by viewModels() + val viewModel by viewModels() installSplashScreen().apply { - setKeepOnScreenCondition { !viewModel.appViewState.value.isAppReady } + setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded } } setContent { + val context = LocalContext.current val isTv = isRunningOnTv() - val appUiState by viewModel.uiState.collectAsStateWithLifecycle() - val appViewState by viewModel.appViewState.collectAsStateWithLifecycle() - + val appState by viewModel.container.stateFlow.collectAsStateWithLifecycle() val navController = rememberNavController() - val backStackEntry by navController.currentBackStackEntryAsState() - val navBarState by - currentNavBackStackEntryAsNavBarState( - navController, - backStackEntry, - viewModel, - appUiState, - appViewState, - ) + val scope = rememberCoroutineScope() + + var pinManagerInitialized by remember { mutableStateOf(false) } + + LaunchedEffect(appState.isAppLoaded) { + if (appState.isAppLoaded) { + if (appState.pinLockEnabled && !pinManagerInitialized) { + PinManager.initialize(this@MainActivity) + pinManagerInitialized = true + } + appState.locale.let { LocaleUtil.changeLocale(it) } + } + } + val snackbar = remember { SnackbarHostState() } var showVpnPermissionDialog by remember { mutableStateOf(false) } var vpnPermissionDenied by remember { mutableStateOf(false) } + var requestingAppMode by remember { + mutableStateOf>(Pair(null, null)) + } val vpnActivity = rememberLauncherForActivityResult( @@ -159,214 +149,237 @@ class MainActivity : AppCompatActivity() { } else { vpnPermissionDenied = false showVpnPermissionDialog = false + val (appMode, config) = requestingAppMode + when (appMode) { + AppMode.VPN -> if (config != null) viewModel.startTunnel(config) + AppMode.LOCK_DOWN -> viewModel.setAppMode(AppMode.LOCK_DOWN) + else -> Unit + } } + requestingAppMode = Pair(null, null) }, ) - LaunchedEffect(appUiState.tunnels) { - if (!appViewState.isAppReady) { - viewModel.handleEvent(AppEvent.AppReadyCheck(appUiState.tunnels)) - } - } - val batteryActivity = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { _: ActivityResult -> - viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown) + viewModel.disableBatteryOptimizationsShown() } - with(appViewState) { - LaunchedEffect(isConfigChanged) { - if (isConfigChanged) { - Intent(this@MainActivity, MainActivity::class.java).also { - startActivity(it) - exitProcess(0) - } + fun requestDisableBatteryOptimizations() { + batteryActivity.launch( + Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = "package:${this@MainActivity.packageName}".toUri() } - } - LaunchedEffect(errorMessage) { - errorMessage?.let { - snackbar.showSnackbar(it.asString(this@MainActivity)) - viewModel.handleEvent(AppEvent.MessageShown) - } - } - LaunchedEffect(popBackStack) { - if (popBackStack) { - navController.popBackStack() - viewModel.handleEvent(AppEvent.PopBackStack(false)) - } - } - LaunchedEffect(requestVpnPermission) { - if (requestVpnPermission) { - if (!vpnPermissionDenied) { + ) + } + + LaunchedEffect(Unit) { + viewModel.globalSideEffect.collect { sideEffect -> + when (sideEffect) { + GlobalSideEffect.ConfigChanged -> restartApp() + GlobalSideEffect.PopBackStack -> navController.popBackStack() + GlobalSideEffect.RequestBatteryOptimizationDisabled -> + requestDisableBatteryOptimizations() + is GlobalSideEffect.RequestVpnPermission -> { + requestingAppMode = Pair(sideEffect.requestingMode, sideEffect.config) vpnActivity.launch(VpnService.prepare(this@MainActivity)) - } else { - showVpnPermissionDialog = true } - viewModel.handleEvent(AppEvent.VpnPermissionRequested) - } - } - LaunchedEffect(requestBatteryPermission) { - if (requestBatteryPermission) { - batteryActivity.launch( - Intent().apply { - action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = "package:${this@MainActivity.packageName}".toUri() + is GlobalSideEffect.ShareFile -> context.launchShareFile(sideEffect.file) + is GlobalSideEffect.Snackbar -> + scope.launch { + snackbar.showSnackbar(sideEffect.message.asString(context)) } - ) + is GlobalSideEffect.Toast -> + scope.launch { context.showToast(sideEffect.message.asString(context)) } + is GlobalSideEffect.LaunchUrl -> context.openWebUrl(sideEffect.url) + is GlobalSideEffect.InstallApk -> context.installApk(sideEffect.apk) } } } - CompositionLocalProvider(LocalIsAndroidTV provides isTv) { - CompositionLocalProvider(LocalNavController provides navController) { - WireguardAutoTunnelTheme(theme = appUiState.appState.theme) { - VpnDeniedDialog( - showVpnPermissionDialog, - onDismiss = { - showVpnPermissionDialog = false - vpnPermissionDenied = false + if (!appState.isAppLoaded) return@setContent + + CompositionLocalProvider( + LocalIsAndroidTV provides isTv, + LocalSharedVm provides viewModel, + LocalNavController provides navController, + ) { + WireguardAutoTunnelTheme(theme = appState.theme) { + VpnDeniedDialog( + showVpnPermissionDialog, + onDismiss = { + showVpnPermissionDialog = false + vpnPermissionDenied = false + }, + ) + + Box(modifier = Modifier.fillMaxSize()) { + if (appState.settings.appMode == AppMode.LOCK_DOWN) { + AppAlertBanner( + stringResource(R.string.locked_down).uppercase(Locale.getDefault()), + OffWhite, + AlertRed, + modifier = Modifier.fillMaxWidth().zIndex(2f), + ) + } + + Scaffold( + snackbarHost = { + SnackbarHost(snackbar) { snackbarData -> + CustomSnackBar( + snackbarData.visuals.message, + isRtl = false, + containerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + ) + } }, - ) - - Box(modifier = Modifier.fillMaxSize()) { - // Top banner if in locked down mode - if (appUiState.appSettings.appMode == AppMode.LOCK_DOWN) { - AppAlertBanner( - stringResource(R.string.locked_down) - .uppercase(Locale.getDefault()), - OffWhite, - AlertRed, - modifier = - Modifier.fillMaxWidth().zIndex(2f), // Draw above everything + topBar = { DynamicTopAppBar(appState.navBarState) }, + bottomBar = { + BottomNavbar( + appState.isAutoTunnelActive, + appState.navBarState, + navController, ) - } - - Scaffold( + }, + ) { padding -> + Box( modifier = - Modifier.pointerInput(Unit) { - detectTapGestures { - viewModel.handleEvent(AppEvent.ClearSelectedTunnels) - } - }, - snackbarHost = { - SnackbarHost(snackbar) { snackbarData: SnackbarData -> - CustomSnackBar( - snackbarData.visuals.message, - isRtl = false, - containerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 2.dp - ), - ) - } - }, - topBar = { DynamicTopAppBar(navBarState) }, - bottomBar = { - AnimatedVisibility( - visible = navBarState.showBottom, - enter = slideInVertically(initialOffsetY = { it }), - exit = slideOutVertically(targetOffsetY = { it }), - ) { - BottomNavbar(appUiState = appUiState) - } - }, - ) { padding -> - Box( - modifier = - Modifier.fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .padding(padding) - .consumeWindowInsets(padding) - .imePadding() + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + ) { + NavHost( + navController = navController, + startDestination = + if (appState.pinLockEnabled && !appState.isAuthorized) + Route.Lock + else Route.TunnelsGraph, ) { - NavHost( - navController, - startDestination = - (if (appUiState.appState.isPinLockEnabled) Route.Lock - else Route.Main), + composable { PinLockScreen() } + navigation( + startDestination = Route.Tunnels ) { - composable { - MainScreen(appUiState, appViewState, viewModel) + composable { + val viewModel = + it.sharedViewModel(navController) + TunnelsScreen(viewModel) } - composable { - SettingsScreen(appUiState, appViewState, viewModel) + composable { + val viewModel = + it.sharedViewModel(navController) + SortScreen(viewModel) } + composable { backStackEntry -> + val args = backStackEntry.toRoute() + val viewModel = + backStackEntry.sharedViewModel( + navController + ) + TunnelOptionsScreen(args.id, viewModel) + } + composable { backStackEntry -> + val args = backStackEntry.toRoute() + SplitTunnelScreen(args.id) + } + composable { backStackEntry -> + val args = + backStackEntry.toRoute() + val viewModel = + backStackEntry.sharedViewModel( + navController + ) + TunnelAutoTunnelScreen(args.id, viewModel) + } + composable { backStackEntry -> + val args = backStackEntry.toRoute() + val viewModel = + backStackEntry.sharedViewModel( + navController + ) + ConfigScreen(args.id, viewModel) + } + } + + navigation( + startDestination = + if (appState.isLocationDisclosureShown) Route.AutoTunnel + else Route.LocationDisclosure + ) { composable { + val viewModel = + it.sharedViewModel( + navController + ) LocationDisclosureScreen(viewModel) } composable { - AutoTunnelScreen(appUiState, viewModel) + val viewModel = + it.sharedViewModel( + navController + ) + AutoTunnelScreen(viewModel) } - composable { AppearanceScreen() } - composable { - LanguageScreen(appUiState, viewModel) - } - composable { - DisplayScreen(appUiState, viewModel) - } - composable { - SupportScreen(appViewModel = viewModel) - } - composable { LicenseScreen() } - composable { - AutoTunnelAdvancedScreen(appUiState, viewModel) + composable { + val viewModel = + it.sharedViewModel( + navController + ) + AutoTunnelAdvancedScreen(viewModel) } composable { - WifiDetectionMethodScreen(appUiState, viewModel) + val viewModel = + it.sharedViewModel( + navController + ) + WifiDetectionMethodScreen(viewModel) } - composable { - LogsScreen(appViewState, viewModel) + } + + navigation( + startDestination = Route.Settings + ) { + composable { + val viewModel = + it.sharedViewModel(navController) + SettingsScreen(viewModel) } - composable { backStack -> - val args = backStack.toRoute() - val config = - appUiState.tunnels.firstOrNull { it.id == args.id } - ConfigScreen(config, appUiState, viewModel) - } - composable { backStack -> - val args = backStack.toRoute() - appUiState.tunnels - .firstOrNull { it.id == args.id } - ?.let { config -> - TunnelOptionsScreen( - config, - viewModel, - appViewState, - appUiState.appSettings, - ) - } - } - composable { PinLockScreen(viewModel) } - composable { - SplitTunnelScreen(viewModel) - } - composable { backStack -> - val args = backStack.toRoute() - appUiState.tunnels - .firstOrNull { it.id == args.id } - ?.let { - TunnelAutoTunnelScreen( - it, - appUiState.appSettings, - viewModel, - ) - } - } - composable { SortScreen(appUiState, viewModel) } composable { - TunnelMonitoringScreen(appUiState, viewModel) - } - composable { - ProxySettingsScreen(appUiState, viewModel) + val viewModel = + it.sharedViewModel(navController) + TunnelMonitoringScreen(viewModel) } composable { - SystemFeaturesScreen(appUiState, viewModel) + val viewModel = + it.sharedViewModel(navController) + SystemFeaturesScreen(viewModel) } composable { - DnsSettingsScreen(appUiState, viewModel) + val viewModel = + it.sharedViewModel(navController) + DnsSettingsScreen(viewModel) } + composable { ProxySettingsScreen() } + composable { AppearanceScreen() } + composable { LanguageScreen() } + composable { DisplayScreen() } + composable { LogsScreen() } + } + + navigation( + startDestination = Route.Support + ) { + composable { + val viewModel = + it.sharedViewModel(navController) + SupportScreen(viewModel) + } + composable { LicenseScreen() } } } } @@ -389,15 +402,15 @@ class MainActivity : AppCompatActivity() { } fun performBackup() = - lifecycleScope.launch(ioDispatcher) { + lifecycleScope.launch { roomBackup .database(appDatabase) .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) .enableLogDebug(true) .maxFileCount(5) .apply { - onCompleteListener { success, message, exitCode -> - lifecycleScope.launch(mainDispatcher) { + onCompleteListener { success, _, _ -> + lifecycleScope.launch { if (success) { showToast( getString( @@ -406,7 +419,9 @@ class MainActivity : AppCompatActivity() { ) ) restartApp() - } else showToast(R.string.backup_failed) + } else { + showToast(R.string.backup_failed) + } } } } @@ -420,8 +435,8 @@ class MainActivity : AppCompatActivity() { .enableLogDebug(true) .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) .apply { - onCompleteListener { success, message, exitCode -> - lifecycleScope.launch(mainDispatcher) { + onCompleteListener { success, _, _ -> + lifecycleScope.launch { if (success) { showToast( getString( @@ -430,7 +445,9 @@ class MainActivity : AppCompatActivity() { ) ) restartApp() - } else showToast(R.string.restore_failed) + } else { + showToast(R.string.restore_failed) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 792243d0..096e7135 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -15,14 +15,16 @@ import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.MainDispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import timber.log.Timber @HiltAndroidApp @@ -64,6 +66,11 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider { Timber.plant(ReleaseTree()) } + applicationScope.launch(ioDispatcher) { + launch { if (appDataRepository.appState.isLocalLogsEnabled()) logReader.start() } + launch { notificationMonitor.handleApplicationNotifications() } + } + GoBackend.setAlwaysOnCallback { applicationScope.launch { val settings = appDataRepository.settings.get() @@ -77,16 +84,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider { } ServiceWorker.start(this) - - applicationScope.launch { - launch { notificationMonitor.handleApplicationNotifications() } - appDataRepository.appState.getLocale()?.let { - withContext(mainDispatcher) { LocaleUtil.changeLocale(it) } - } - appDataRepository.appState.isLocalLogsEnabled().let { enabled -> - if (enabled) logReader.start() - } - } } override fun onTerminate() { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt index 258d45a9..93335ee4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt @@ -16,9 +16,9 @@ interface NotificationManager { title: String = "", actions: Collection = emptyList(), description: String = "", - showTimestamp: Boolean = false, + showTimestamp: Boolean = true, importance: Int = NotificationManager.IMPORTANCE_HIGH, - onGoing: Boolean = true, + onGoing: Boolean = false, onlyAlertOnce: Boolean = true, ): Notification @@ -27,9 +27,9 @@ interface NotificationManager { title: StringValue, actions: Collection = emptyList(), description: StringValue, - showTimestamp: Boolean = false, + showTimestamp: Boolean = true, importance: Int = NotificationManager.IMPORTANCE_HIGH, - onGoing: Boolean = true, + onGoing: Boolean = false, onlyAlertOnce: Boolean = true, ): Notification diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt index 365607d6..5c8083f4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt @@ -131,6 +131,7 @@ class TunnelForegroundService : LifecycleService() { tunnelConf.id, ) ), + onGoing = true, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt index 8173381d..159098ec 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/AutoTunnelService.kt @@ -19,13 +19,14 @@ import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus.StopReason.Ping import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels +import com.zaneschepke.wireguardautotunnel.util.extensions.to import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -117,6 +118,7 @@ class AutoTunnelService : LifecycleService() { NotificationAction.AUTO_TUNNEL_OFF ) ), + onGoing = true, ) ServiceCompat.startForeground( this, @@ -240,7 +242,7 @@ class AutoTunnelService : LifecycleService() { } // all relevant settings to auto tunnel - private fun areAutoTunnelSettingsTheSame(old: AppSettings, new: AppSettings): Boolean { + private fun areAutoTunnelSettingsTheSame(old: GeneralSettings, new: GeneralSettings): Boolean { return (old.isTunnelOnWifiEnabled == new.isTunnelOnWifiEnabled && old.isTunnelOnMobileDataEnabled == new.isTunnelOnMobileDataEnabled && old.isTunnelOnEthernetEnabled == new.isTunnelOnEthernetEnabled && @@ -254,7 +256,7 @@ class AutoTunnelService : LifecycleService() { old.isStopOnNoInternetEnabled == new.isStopOnNoInternetEnabled) } - private fun combineSettings(): Flow> { + private fun combineSettings(): Flow> { return combine( appDataRepository .get() @@ -302,7 +304,7 @@ class AutoTunnelService : LifecycleService() { .distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame) .map { NetworkPermissionState( - it.settings.wifiDetectionMethod, + it.settings.wifiDetectionMethod.to(), it.networkState.locationServicesEnabled == true, it.networkState.locationPermissionGranted == true, (it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } || diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt index fa3f242c..4cd51791 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt @@ -1,6 +1,6 @@ package com.zaneschepke.wireguardautotunnel.core.service.autotunnel -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState import com.zaneschepke.wireguardautotunnel.domain.state.PingState @@ -11,7 +11,7 @@ import org.amnezia.awg.crypto.Key sealed class StateChange { data class NetworkChange(val networkState: NetworkState) : StateChange() - data class SettingsChange(val settings: AppSettings, val tunnels: Tunnels) : StateChange() + data class SettingsChange(val settings: GeneralSettings, val tunnels: Tunnels) : StateChange() data class ActiveTunnelsChange(val activeTunnels: Map) : StateChange() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt index 5613ceae..b0e815cc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt @@ -7,7 +7,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.PingState @@ -42,13 +42,13 @@ constructor( @OptIn(ExperimentalAtomicApi::class) private val tunnelProviderFlow: StateFlow = run { val currentBackend = AtomicReference(userspaceTunnel) - val currentSettings = AtomicReference(AppSettings()) + val currentSettings = AtomicReference(GeneralSettings()) val initialEmit = AtomicBoolean(true) appDataRepository.settings.flow .filterNotNull() // ignore default state - .filterNot { it == AppSettings() } + .filterNot { it == GeneralSettings() } .distinctUntilChanged { old, new -> old.appMode == new.appMode && old.isLanOnKillSwitchEnabled == new.isLanOnKillSwitchEnabled diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt index aa911140..7856b1ca 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt @@ -87,7 +87,6 @@ constructor( ) } .build() - backend.setState(tunnel, Tunnel.State.UP, updatedConfig) } catch (e: BackendException) { Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt index e01d9a8a..e2ee7a2e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -12,7 +12,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig @Database( entities = [Settings::class, TunnelConfig::class, ProxySettings::class], - version = 21, + version = 22, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -35,6 +35,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig AutoMigration(from = 18, to = 19, spec = PingMigration::class), AutoMigration(from = 19, to = 20, spec = ProxyMigration::class), AutoMigration(from = 20, to = 21, spec = FixProxySettingsMigration::class), + AutoMigration(from = 21, to = 22), ], exportSchema = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt index 92fca1b1..afe347db 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import timber.log.Timber @@ -85,9 +84,5 @@ class DataStoreManager( } } - fun getFromStoreBlocking(key: Preferences.Key) = runBlocking { - context.dataStore.data.map { it[key] }.first() - } - val preferencesFlow: Flow = context.dataStore.data.flowOn(ioDispatcher) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseCallback.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseCallback.kt index 9735e815..f7faa7f3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseCallback.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseCallback.kt @@ -2,25 +2,15 @@ package com.zaneschepke.wireguardautotunnel.data import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase -import com.zaneschepke.wireguardautotunnel.data.entity.ProxySettings -import com.zaneschepke.wireguardautotunnel.data.entity.Settings import javax.inject.Inject import javax.inject.Provider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class DatabaseCallback @Inject constructor(private val databaseProvider: Provider) : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) - - // Launch coroutine to insert default entry - CoroutineScope(Dispatchers.IO).launch { - val db = databaseProvider.get() - db.settingDao().save(Settings()) - db.proxySettingsDoa().save(ProxySettings()) - } + db.execSQL("INSERT INTO proxy_settings DEFAULT VALUES") + db.execSQL("INSERT INTO Settings DEFAULT VALUES") } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt index 99dacdea..082d6cf9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt @@ -24,6 +24,16 @@ class DatabaseConverters { } } + @TypeConverter + fun setToString(value: Set): String { + return listToString(value.toList()) + } + + @TypeConverter + fun stringToSet(value: String): Set { + return stringToList(value).toSet() + } + @TypeConverter fun fromStatus(status: WifiDetectionMethod): Int = status.value @TypeConverter diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt index 0ed20861..b8d28ba0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/TunnelConfigDao.kt @@ -22,6 +22,8 @@ interface TunnelConfigDao { @Delete suspend fun delete(t: TunnelConfig) + @Delete suspend fun delete(t: TunnelConfigs) + @Query("SELECT COUNT('id') FROM TunnelConfig") suspend fun count(): Long @Query("SELECT * FROM TunnelConfig WHERE tunnel_networks LIKE '%' || :name || '%'") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/ProxySettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/ProxySettings.kt index 7e07da0e..1698692c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/ProxySettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/ProxySettings.kt @@ -7,10 +7,10 @@ import androidx.room.PrimaryKey @Entity(tableName = "proxy_settings") data class ProxySettings( @PrimaryKey(autoGenerate = true) val id: Long = 0, - @ColumnInfo(name = "socks5_proxy_enabled", defaultValue = "false") + @ColumnInfo(name = "socks5_proxy_enabled", defaultValue = "0") val socks5ProxyEnabled: Boolean = false, @ColumnInfo(name = "socks5_proxy_bind_address") val socks5ProxyBindAddress: String? = null, - @ColumnInfo(name = "http_proxy_enable", defaultValue = "false") + @ColumnInfo(name = "http_proxy_enable", defaultValue = "0") val httpProxyEnabled: Boolean = false, @ColumnInfo(name = "http_proxy_bind_address") val httpProxyBindAddress: String? = null, @ColumnInfo(name = "proxy_username") val proxyUsername: String? = null, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/Settings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/Settings.kt index 93ecb302..26892247 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/Settings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/Settings.kt @@ -10,38 +10,40 @@ import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod @Entity data class Settings( @PrimaryKey(autoGenerate = true) val id: Int = 0, - @ColumnInfo(name = "is_tunnel_enabled") val isAutoTunnelEnabled: Boolean = false, - @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") + @ColumnInfo(name = "is_tunnel_enabled", defaultValue = "0") + val isAutoTunnelEnabled: Boolean = false, + @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled", defaultValue = "0") val isTunnelOnMobileDataEnabled: Boolean = false, - @ColumnInfo(name = "trusted_network_ssids") val trustedNetworkSSIDs: List = emptyList(), - @ColumnInfo(name = "is_always_on_vpn_enabled") val isAlwaysOnVpnEnabled: Boolean = false, - @ColumnInfo(name = "is_tunnel_on_ethernet_enabled") + @ColumnInfo(name = "trusted_network_ssids", defaultValue = "") + val trustedNetworkSSIDs: Set = emptySet(), + @ColumnInfo(name = "is_always_on_vpn_enabled", defaultValue = "0") + val isAlwaysOnVpnEnabled: Boolean = false, + @ColumnInfo(name = "is_tunnel_on_ethernet_enabled", defaultValue = "0") val isTunnelOnEthernetEnabled: Boolean = false, - @ColumnInfo(name = "is_shortcuts_enabled", defaultValue = "false") + @ColumnInfo(name = "is_shortcuts_enabled", defaultValue = "0") val isShortcutsEnabled: Boolean = false, - @ColumnInfo(name = "is_tunnel_on_wifi_enabled", defaultValue = "false") + @ColumnInfo(name = "is_tunnel_on_wifi_enabled", defaultValue = "0") val isTunnelOnWifiEnabled: Boolean = false, - @ColumnInfo(name = "is_restore_on_boot_enabled", defaultValue = "false") + @ColumnInfo(name = "is_restore_on_boot_enabled", defaultValue = "0") val isRestoreOnBootEnabled: Boolean = false, - @ColumnInfo(name = "is_multi_tunnel_enabled", defaultValue = "false") + @ColumnInfo(name = "is_multi_tunnel_enabled", defaultValue = "0") val isMultiTunnelEnabled: Boolean = false, - @ColumnInfo(name = "is_ping_enabled", defaultValue = "false") - val isPingEnabled: Boolean = false, - @ColumnInfo(name = "is_wildcards_enabled", defaultValue = "false") + @ColumnInfo(name = "is_ping_enabled", defaultValue = "0") val isPingEnabled: Boolean = false, + @ColumnInfo(name = "is_wildcards_enabled", defaultValue = "0") val isWildcardsEnabled: Boolean = false, - @ColumnInfo(name = "is_stop_on_no_internet_enabled", defaultValue = "false") + @ColumnInfo(name = "is_stop_on_no_internet_enabled", defaultValue = "0") val isStopOnNoInternetEnabled: Boolean = false, - @ColumnInfo(name = "is_lan_on_kill_switch_enabled", defaultValue = "false") + @ColumnInfo(name = "is_lan_on_kill_switch_enabled", defaultValue = "0") val isLanOnKillSwitchEnabled: Boolean = false, @ColumnInfo(name = "debounce_delay_seconds", defaultValue = "3") val debounceDelaySeconds: Int = 3, - @ColumnInfo(name = "is_disable_kill_switch_on_trusted_enabled", defaultValue = "false") + @ColumnInfo(name = "is_disable_kill_switch_on_trusted_enabled", defaultValue = "0") val isDisableKillSwitchOnTrustedEnabled: Boolean = false, - @ColumnInfo(name = "is_tunnel_on_unsecure_enabled", defaultValue = "false") + @ColumnInfo(name = "is_tunnel_on_unsecure_enabled", defaultValue = "0") val isTunnelOnUnsecureEnabled: Boolean = false, @ColumnInfo(name = "wifi_detection_method", defaultValue = "0") val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), - @ColumnInfo(name = "is_ping_monitoring_enabled", defaultValue = "true") + @ColumnInfo(name = "is_ping_monitoring_enabled", defaultValue = "1") val isPingMonitoringEnabled: Boolean = true, @ColumnInfo(name = "tunnel_ping_interval_sec", defaultValue = "30") val tunnelPingIntervalSeconds: Int = 30, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt index 52abb624..aba183e5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/TunnelConfig.kt @@ -11,7 +11,7 @@ data class TunnelConfig( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "wg_quick") val wgQuick: String, @ColumnInfo(name = "tunnel_networks", defaultValue = "") - val tunnelNetworks: List = listOf(), + val tunnelNetworks: Set = setOf(), @ColumnInfo(name = "is_mobile_data_tunnel", defaultValue = "false") val isMobileDataTunnel: Boolean = false, @ColumnInfo(name = "is_primary_tunnel", defaultValue = "false") @@ -27,7 +27,7 @@ data class TunnelConfig( val isIpv4Preferred: Boolean = true, @ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0, @ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]") - val autoTunnelApps: List = listOf(), + val autoTunnelApps: Set = setOf(), ) { companion object { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt index 5ca2dc80..1713b1dc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/SettingsMapper.kt @@ -1,14 +1,13 @@ package com.zaneschepke.wireguardautotunnel.data.mapper -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.wireguardautotunnel.data.entity.Settings import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol import com.zaneschepke.wireguardautotunnel.data.model.DnsSettings import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings -fun Settings.toAppSettings(): AppSettings { - return AppSettings( +fun Settings.toAppSettings(): GeneralSettings { + return GeneralSettings( id = id, isAutoTunnelEnabled = isAutoTunnelEnabled, isTunnelOnMobileDataEnabled = isTunnelOnMobileDataEnabled, @@ -26,8 +25,7 @@ fun Settings.toAppSettings(): AppSettings { debounceDelaySeconds = debounceDelaySeconds, isDisableKillSwitchOnTrustedEnabled = isDisableKillSwitchOnTrustedEnabled, isTunnelOnUnsecureEnabled = isTunnelOnUnsecureEnabled, - wifiDetectionMethod = - AndroidNetworkMonitor.WifiDetectionMethod.fromValue(wifiDetectionMethod.value), + wifiDetectionMethod = WifiDetectionMethod.fromValue(wifiDetectionMethod.value), tunnelPingIntervalSeconds = tunnelPingIntervalSeconds, tunnelPingAttempts = tunnelPingAttempts, tunnelPingTimeoutSeconds = tunnelPingTimeoutSeconds, @@ -37,7 +35,7 @@ fun Settings.toAppSettings(): AppSettings { ) } -fun AppSettings.toSettings(): Settings { +fun GeneralSettings.toSettings(): Settings { return Settings( id = id, isAutoTunnelEnabled = isAutoTunnelEnabled, @@ -66,7 +64,7 @@ fun AppSettings.toSettings(): Settings { ) } -fun AppSettings.toDomain(): DnsSettings { +fun GeneralSettings.toDomain(): DnsSettings { return DnsSettings( protocol = DnsProtocol.entries.toTypedArray().getOrElse(dnsProtocol.value) { DnsProtocol.SYSTEM }, @@ -74,6 +72,6 @@ fun AppSettings.toDomain(): DnsSettings { ) } -fun DnsSettings.toAppSettings(existing: AppSettings): AppSettings { +fun DnsSettings.toAppSettings(existing: GeneralSettings): GeneralSettings { return existing.copy(dnsProtocol = protocol, dnsEndpoint = endpoint) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt index 6330781c..4fb8cc6c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/model/WifiDetectionMethod.kt @@ -6,6 +6,10 @@ enum class WifiDetectionMethod(val value: Int) { ROOT(2), SHIZUKU(3); + fun needsLocationPermissions(): Boolean { + return this == LEGACY || this == DEFAULT + } + companion object { fun fromValue(value: Int): WifiDetectionMethod = entries.find { it.value == value } ?: DEFAULT diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt index cdce527e..a39c0533 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt @@ -7,7 +7,7 @@ import javax.inject.Inject class AppDataRoomRepository @Inject constructor( - override val settings: AppSettingRepository, + override val settings: GeneralSettingRepository, override val tunnels: TunnelRepository, override val appState: AppStateRepository, override val proxySettings: ProxySettingsRepository, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/DataStoreAppStateRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/DataStoreAppStateRepository.kt index 55f64c20..ba671bee 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/DataStoreAppStateRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/DataStoreAppStateRepository.kt @@ -3,15 +3,25 @@ package com.zaneschepke.wireguardautotunnel.data.repository import com.zaneschepke.wireguardautotunnel.data.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.entity.GeneralState import com.zaneschepke.wireguardautotunnel.data.mapper.GeneralStateMapper +import com.zaneschepke.wireguardautotunnel.di.ApplicationScope +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.model.AppState import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.ui.theme.Theme +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import timber.log.Timber -class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager) : - AppStateRepository { +class DataStoreAppStateRepository( + private val dataStoreManager: DataStoreManager, + @ApplicationScope private val applicationScope: CoroutineScope, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : AppStateRepository { override suspend fun isLocationDisclosureShown(): Boolean { return dataStoreManager.getFromStore(DataStoreManager.locationDisclosureShown) ?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT @@ -167,4 +177,9 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager } ?: GeneralState() } .map(GeneralStateMapper::toAppState) + .stateIn( + scope = applicationScope + ioDispatcher, + started = SharingStarted.Eagerly, + initialValue = AppState(), + ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt new file mode 100644 index 00000000..0d3281e7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/InstalledAndroidPackageRepository.kt @@ -0,0 +1,92 @@ +package com.zaneschepke.wireguardautotunnel.data.repository + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import com.zaneschepke.wireguardautotunnel.di.ApplicationScope +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage +import com.zaneschepke.wireguardautotunnel.domain.repository.InstalledPackageRepository +import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages +import com.zaneschepke.wireguardautotunnel.util.extensions.getFriendlyAppName +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@Singleton +class InstalledAndroidPackageRepository( + private val context: Context, + @ApplicationScope val applicationScope: CoroutineScope, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : InstalledPackageRepository { + + private var cachedPackages: List? = null + + init { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_REMOVED, + Intent.ACTION_PACKAGE_CHANGED -> { + // don't update if we have nothing cached + if (cachedPackages == null) return + Timber.d("Updating installed packages cache") + applicationScope.launch { refreshInstalledPackages() } + } + } + } + } + val filter = + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_CHANGED) + addDataScheme("package") + } + context.registerReceiver(receiver, filter) + } + + override suspend fun getInstalledPackages(): List = + withContext(ioDispatcher) { + cachedPackages?.let { + return@withContext it + } + refreshInstalledPackages() + } + + override suspend fun refreshInstalledPackages(): List = + withContext(ioDispatcher) { + val packages = context.getAllInternetCapablePackages() + + val installedPackages = + packages.mapNotNull { packageInfo -> + try { + val appInfo = + context.packageManager.getApplicationInfo(packageInfo.packageName, 0) + InstalledPackage( + name = + context.packageManager.getFriendlyAppName( + packageInfo.packageName, + appInfo, + ), + packageName = packageInfo.packageName, + uId = appInfo.uid, + ) + } catch (e: PackageManager.NameNotFoundException) { + Timber.e(e) + null + } + } + + cachedPackages = installedPackages + + installedPackages + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt index 3d2c0b96..a5b91168 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomSettingsRepository.kt @@ -5,8 +5,8 @@ import com.zaneschepke.wireguardautotunnel.data.entity.Settings import com.zaneschepke.wireguardautotunnel.data.mapper.toAppSettings import com.zaneschepke.wireguardautotunnel.data.mapper.toSettings import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -15,16 +15,16 @@ import kotlinx.coroutines.withContext class RoomSettingsRepository( private val settingsDoa: SettingsDao, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, -) : AppSettingRepository { +) : GeneralSettingRepository { - override suspend fun save(appSettings: AppSettings) { - withContext(ioDispatcher) { settingsDoa.save(appSettings.toSettings()) } + override suspend fun save(generalSettings: GeneralSettings) { + withContext(ioDispatcher) { settingsDoa.save(generalSettings.toSettings()) } } override val flow = settingsDoa.getSettingsFlow().flowOn(ioDispatcher).map { it.toAppSettings() } - override suspend fun get(): AppSettings { + override suspend fun get(): GeneralSettings { return withContext(ioDispatcher) { (settingsDoa.getAll().firstOrNull() ?: Settings()).toAppSettings() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt index bc7b711e..b535dae7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/RoomTunnelRepository.kt @@ -105,4 +105,10 @@ class RoomTunnelRepository( tunnelConfigDao.findByPrimary().map(TunnelConfigMapper::toTunnelConf) } } + + override suspend fun delete(tunnels: List) { + withContext(ioDispatcher) { + tunnelConfigDao.delete(tunnels.map { TunnelConfigMapper.toTunnelConfig(it) }) + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt index 9f28c4e8..3dddb594 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) class AppModule { + @Singleton @ApplicationScope @Provides diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt index 522a4e34..47f44f36 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt @@ -22,10 +22,28 @@ import dagger.hilt.components.SingletonComponent import io.ktor.client.* import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope @Module @InstallIn(SingletonComponent::class) class RepositoryModule { + + @Provides + @Singleton + fun provideGlobalEffectRepository(): GlobalEffectRepository { + return GlobalEffectRepository() + } + + @Provides + @Singleton + fun provideInstalledPackageRepository( + @ApplicationContext context: Context, + @ApplicationScope applicationScope: CoroutineScope, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): InstalledPackageRepository { + return InstalledAndroidPackageRepository(context, applicationScope, ioDispatcher) + } + @Provides @Singleton fun provideDatabase( @@ -74,7 +92,7 @@ class RepositoryModule { fun provideSettingsRepository( settingsDao: SettingsDao, @IoDispatcher ioDispatcher: CoroutineDispatcher, - ): AppSettingRepository { + ): GeneralSettingRepository { return RoomSettingsRepository(settingsDao, ioDispatcher) } @@ -98,14 +116,18 @@ class RepositoryModule { @Provides @Singleton - fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository { - return DataStoreAppStateRepository(dataStoreManager) + fun provideGeneralStateRepository( + dataStoreManager: DataStoreManager, + @ApplicationScope applicationScope: CoroutineScope, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): AppStateRepository { + return DataStoreAppStateRepository(dataStoreManager, applicationScope, ioDispatcher) } @Provides @Singleton fun provideAppDataRepository( - settingsRepository: AppSettingRepository, + settingsRepository: GeneralSettingRepository, tunnelRepository: TunnelRepository, appStateRepository: AppStateRepository, proxySettingsRepository: ProxySettingsRepository, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt index c5fa653b..038ffb85 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt @@ -10,7 +10,8 @@ import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.* import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.util.extensions.to import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import dagger.Module import dagger.Provides @@ -140,7 +141,7 @@ class TunnelModule { @Singleton fun provideNetworkMonitor( @ApplicationContext context: Context, - settingsRepository: AppSettingRepository, + settingsRepository: GeneralSettingRepository, @ApplicationScope applicationScope: CoroutineScope, @AppShell appShell: RootShell, ): NetworkMonitor { @@ -151,7 +152,7 @@ class TunnelModule { get() = settingsRepository.flow .distinctUntilChangedBy { it.wifiDetectionMethod } - .map { it.wifiDetectionMethod } + .map { it.wifiDetectionMethod.to() } override val rootShell: RootShell get() = appShell diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppState.kt index c152b52b..2bb3f25f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppState.kt @@ -3,14 +3,14 @@ package com.zaneschepke.wireguardautotunnel.domain.model import com.zaneschepke.wireguardautotunnel.ui.theme.Theme data class AppState( - val isLocationDisclosureShown: Boolean, - val isBatteryOptimizationDisableShown: Boolean, - val isPinLockEnabled: Boolean, - val expandedTunnelIds: List, - val isLocalLogsEnabled: Boolean, - val isRemoteControlEnabled: Boolean, - val showDetailedPingStats: Boolean, - val remoteKey: String?, - val locale: String?, - val theme: Theme, + val isLocationDisclosureShown: Boolean = false, + val isBatteryOptimizationDisableShown: Boolean = false, + val isPinLockEnabled: Boolean = false, + val expandedTunnelIds: List = emptyList(), + val isLocalLogsEnabled: Boolean = false, + val isRemoteControlEnabled: Boolean = false, + val showDetailedPingStats: Boolean = false, + val remoteKey: String? = null, + val locale: String? = null, + val theme: Theme = Theme.AUTOMATIC, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt similarity index 77% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt index faa74c2c..4787d374 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt @@ -1,14 +1,14 @@ package com.zaneschepke.wireguardautotunnel.domain.model -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol +import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod -data class AppSettings( +data class GeneralSettings( val id: Int = 0, val isAutoTunnelEnabled: Boolean = false, val isTunnelOnMobileDataEnabled: Boolean = false, - val trustedNetworkSSIDs: List = emptyList(), + val trustedNetworkSSIDs: Set = emptySet(), val isAlwaysOnVpnEnabled: Boolean = false, val isTunnelOnEthernetEnabled: Boolean = false, val isShortcutsEnabled: Boolean = false, @@ -24,10 +24,9 @@ data class AppSettings( val debounceDelaySeconds: Int = 3, val isDisableKillSwitchOnTrustedEnabled: Boolean = false, val isTunnelOnUnsecureEnabled: Boolean = false, - val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod = - AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT, - val tunnelPingIntervalSeconds: Int = 30, - val tunnelPingAttempts: Int = 3, + val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.DEFAULT, + val tunnelPingIntervalSeconds: Int = PING_INTERVAL_DEFAULT, + val tunnelPingAttempts: Int = PING_ATTEMPTS_DEFAULT, val tunnelPingTimeoutSeconds: Int? = null, val appMode: AppMode = AppMode.VPN, val dnsProtocol: DnsProtocol = DnsProtocol.SYSTEM, @@ -44,4 +43,9 @@ data class AppSettings( """ .trimIndent() } + + companion object { + const val PING_INTERVAL_DEFAULT = 30 + const val PING_ATTEMPTS_DEFAULT = 3 + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/InstalledPackage.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/InstalledPackage.kt new file mode 100644 index 00000000..7e188fd2 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/InstalledPackage.kt @@ -0,0 +1,3 @@ +package com.zaneschepke.wireguardautotunnel.domain.model + +data class InstalledPackage(val name: String, val packageName: String, val uId: Int) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt index 18116de8..989ac075 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/TunnelConf.kt @@ -1,16 +1,19 @@ package com.zaneschepke.wireguardautotunnel.domain.model +import android.os.Parcelable import com.wireguard.android.backend.Tunnel import com.wireguard.config.Config import com.zaneschepke.wireguardautotunnel.util.extensions.* import java.io.InputStream import java.nio.charset.StandardCharsets +import kotlinx.parcelize.Parcelize +@Parcelize data class TunnelConf( val id: Int = 0, val tunName: String, val wgQuick: String, - val tunnelNetworks: List = emptyList(), + val tunnelNetworks: Set = emptySet(), val isMobileDataTunnel: Boolean = false, val isPrimaryTunnel: Boolean = false, val amQuick: String, @@ -21,7 +24,7 @@ data class TunnelConf( val isIpv4Preferred: Boolean = true, val position: Int = 0, @Transient private var stateChangeCallback: ((Any) -> Unit)? = null, -) : Tunnel, org.amnezia.awg.backend.Tunnel { +) : Tunnel, org.amnezia.awg.backend.Tunnel, Parcelable { val isNameKernelCompatible: Boolean = (name.length <= 15) @@ -61,7 +64,7 @@ data class TunnelConf( id: Int = this.id, tunName: String = this.tunName, wgQuick: String = this.wgQuick, - tunnelNetworks: List = this.tunnelNetworks, + tunnelNetworks: Set = this.tunnelNetworks, isMobileDataTunnel: Boolean = this.isMobileDataTunnel, isPrimaryTunnel: Boolean = this.isPrimaryTunnel, amQuick: String = this.amQuick, @@ -140,7 +143,17 @@ data class TunnelConf( } } - fun tunnelConfigFromAmConfig( + fun tunnelConfFromQuick(amQuick: String, name: String? = null): TunnelConf { + val config = configFromAmQuick(amQuick) + val wgQuick = config.toWgQuickString() + return TunnelConf( + tunName = name ?: config.defaultName(), + wgQuick = wgQuick, + amQuick = amQuick, + ) + } + + private fun tunnelConfFromAmConfig( config: org.amnezia.awg.config.Config, name: String? = null, ): TunnelConf { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppDataRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppDataRepository.kt index cd8d2d8c..5ea6975a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppDataRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppDataRepository.kt @@ -7,7 +7,7 @@ interface AppDataRepository { suspend fun getStartTunnelConfig(): TunnelConf? - val settings: AppSettingRepository + val settings: GeneralSettingRepository val tunnels: TunnelRepository val appState: AppStateRepository diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppSettingRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppSettingRepository.kt deleted file mode 100644 index eecc086e..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppSettingRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.domain.repository - -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import kotlinx.coroutines.flow.Flow - -interface AppSettingRepository { - suspend fun save(appSettings: AppSettings) - - val flow: Flow - - suspend fun get(): AppSettings -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/EffectRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/EffectRepository.kt new file mode 100644 index 00000000..4da14c9e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/EffectRepository.kt @@ -0,0 +1,18 @@ +package com.zaneschepke.wireguardautotunnel.domain.repository + +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +@Singleton +class GlobalEffectRepository { + + private val _globalEffectFlow = + MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val flow = _globalEffectFlow.asSharedFlow() + + suspend fun post(effect: GlobalSideEffect) { + _globalEffectFlow.emit(effect) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt new file mode 100644 index 00000000..607b1d74 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/GeneralSettingRepository.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.wireguardautotunnel.domain.repository + +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings +import kotlinx.coroutines.flow.Flow + +interface GeneralSettingRepository { + suspend fun save(generalSettings: GeneralSettings) + + val flow: Flow + + suspend fun get(): GeneralSettings +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/InstalledPackageRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/InstalledPackageRepository.kt new file mode 100644 index 00000000..b7c9b34f --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/InstalledPackageRepository.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.wireguardautotunnel.domain.repository + +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage + +interface InstalledPackageRepository { + + // gets packages from cache or queries and updates cache if empty + suspend fun getInstalledPackages(): List + + // updates the cache and returns the results + suspend fun refreshInstalledPackages(): List +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt index bdcffd59..379a1327 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/TunnelRepository.kt @@ -34,4 +34,6 @@ interface TunnelRepository { suspend fun findByMobileDataTunnel(): Tunnels suspend fun findPrimary(): Tunnels + + suspend fun delete(tunnels: List) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt new file mode 100644 index 00000000..143f6ea3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/sideeffect/GlobalSideEffect.kt @@ -0,0 +1,27 @@ +package com.zaneschepke.wireguardautotunnel.domain.sideeffect + +import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.util.StringValue +import java.io.File + +sealed class GlobalSideEffect { + data object RequestBatteryOptimizationDisabled : GlobalSideEffect() + + data class Snackbar(val message: StringValue) : GlobalSideEffect() + + data class Toast(val message: StringValue) : GlobalSideEffect() + + data object PopBackStack : GlobalSideEffect() + + data class ShareFile(val file: File) : GlobalSideEffect() + + data class LaunchUrl(val url: String) : GlobalSideEffect() + + data object ConfigChanged : GlobalSideEffect() + + data class RequestVpnPermission(val requestingMode: AppMode, val config: TunnelConf?) : + GlobalSideEffect() + + data class InstallApk(val apk: File) : GlobalSideEffect() +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt index 1aeb305f..3381d92d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/AutoTunnelState.kt @@ -3,14 +3,14 @@ package com.zaneschepke.wireguardautotunnel.domain.state import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.* -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList data class AutoTunnelState( val activeTunnels: Map = emptyMap(), val networkState: NetworkState = NetworkState(), - val settings: AppSettings = AppSettings(), + val settings: GeneralSettings = GeneralSettings(), val tunnels: List = emptyList(), ) { @@ -137,7 +137,7 @@ data class AutoTunnelState( private fun hasTrustedWifiName( wifiName: String, - wifiNames: List = settings.trustedNetworkSSIDs, + wifiNames: Set = settings.trustedNetworkSSIDs, ): Boolean { return if (settings.isWildcardsEnabled) { wifiNames.isMatchingToWildcardList(wifiName) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Locals.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Locals.kt new file mode 100644 index 00000000..e1418b7a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Locals.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation.NavHostController +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel + +val LocalNavController = + compositionLocalOf { error("NavController was not provided") } + +val LocalIsAndroidTV = staticCompositionLocalOf { false } + +val LocalSharedVm = staticCompositionLocalOf { error("No SharedVm") } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt index ab33c77e..3bf0ab18 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt @@ -22,7 +22,7 @@ fun ExpandingRowListItem( text: String, trailing: @Composable () -> Unit, isSelected: Boolean, - expanded: @Composable () -> Unit, + expanded: (@Composable () -> Unit), modifier: Modifier = Modifier, ) { Box( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ActionIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ActionIconButton.kt new file mode 100644 index 00000000..5bd8a558 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ActionIconButton.kt @@ -0,0 +1,21 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.button + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize + +@Composable +fun ActionIconButton(icon: ImageVector, labelRes: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + icon, + contentDescription = stringResource(labelRes), + modifier = Modifier.size(iconSize), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt index 03de884b..7ddf11ca 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/IconSurfaceButton.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType @Composable fun IconSurfaceButton( @@ -55,7 +57,7 @@ fun IconSurfaceButton( ) { leading?.invoke() Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium) + SelectionItemLabel(title, SelectionLabelType.TITLE) description?.let { Text( description, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt index a46e8a60..927d06d4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt @@ -4,24 +4,28 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType @Composable fun SelectionItemButton( - leading: (@Composable () -> Unit)? = null, buttonText: String, - trailing: (@Composable () -> Unit)? = null, + description: String? = null, onClick: () -> Unit, - ripple: Boolean = true, modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + ripple: Boolean = true, ) { Card( modifier = @@ -32,24 +36,38 @@ fun SelectionItemButton( interactionSource = remember { MutableInteractionSource() }, onClick = { onClick() }, ) - .height(56.dp), + .height(IntrinsicSize.Min) + .padding(horizontal = 12.dp) + .padding(end = 12.dp), colors = CardDefaults.cardColors(containerColor = Color.Transparent), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), ) { - Row { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { leading?.let { it() } - Text( - buttonText, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth(3 / 4f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.Start, + modifier = Modifier.weight(1f).padding(end = 12.dp), + verticalArrangement = Arrangement.Center, + ) { + SelectionItemLabel( + buttonText, + SelectionLabelType.TITLE, + modifier = Modifier.weight(1f).padding(end = 24.dp), + ) + description?.let { + SelectionItemLabel( + it, + SelectionLabelType.DESCRIPTION, + modifier = Modifier.weight(1f).padding(end = 24.dp), + ) + } + } } trailing?.let { it() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/surface/SelectionItemLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/surface/SelectionItemLabel.kt index 0d0642f2..5fc987c4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/surface/SelectionItemLabel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/surface/SelectionItemLabel.kt @@ -22,6 +22,7 @@ fun SelectionItemLabel(text: String, labelType: SelectionLabelType, modifier: Mo } enum class SelectionLabelType { + DESCRIPTION, TITLE, } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt index 9fd3abad..c5bc55f1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/functions/Functions.kt @@ -9,8 +9,8 @@ import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.util.FileUtils import timber.log.Timber @Composable @@ -26,9 +26,9 @@ fun rememberFileImportLauncherForResult( super.createIntent(context, input).apply { type = if (isTv) { - Constants.ALLOWED_TV_FILE_TYPES + FileUtils.ALLOWED_TV_FILE_TYPES } else { - Constants.ALL_FILE_TYPES + FileUtils.ALL_FILE_TYPES } } @@ -51,8 +51,8 @@ fun rememberFileImportLauncherForResult( if ( activitiesToResolveIntent.all { val name = it.activityInfo.packageName - name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || - name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) + name.startsWith(FileUtils.GOOGLE_TV_EXPLORER_STUB) || + name.startsWith(FileUtils.ANDROID_TV_EXPLORER_STUB) } ) { onNoFileExplorer() @@ -68,7 +68,7 @@ fun rememberFileImportLauncherForResult( @Composable fun rememberFileExportLauncherForResult( - mimeType: String = Constants.ZIP_FILE_MIME_TYPE, + mimeType: String = FileUtils.ZIP_FILE_MIME_TYPE, onResult: (Uri?) -> Unit, ): ManagedActivityResultLauncher { val isTv = LocalIsAndroidTV.current @@ -82,7 +82,7 @@ fun rememberFileExportLauncherForResult( addCategory(Intent.CATEGORY_OPENABLE) type = if (isTv) { - Constants.ALLOWED_TV_FILE_TYPES + FileUtils.ALLOWED_TV_FILE_TYPES } else { mimeType } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt index 6c3ee958..92582b05 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/CustomSnackbar.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV @Composable fun CustomSnackBar( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/BottomNavItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/BottomNavItem.kt index 5bbea9eb..1112e742 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/BottomNavItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/BottomNavItem.kt @@ -1,12 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.navigation import androidx.compose.ui.graphics.vector.ImageVector -import com.zaneschepke.wireguardautotunnel.ui.Route data class BottomNavItem( val name: String, - val route: Route, val icon: ImageVector, val onClick: () -> Unit, val active: Boolean = false, + val route: Route, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Extensions.kt deleted file mode 100644 index 1e3bc2df..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Extensions.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.navigation - -import android.annotation.SuppressLint -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavHostController -import com.zaneschepke.wireguardautotunnel.ui.Route -import kotlin.reflect.KClass - -@SuppressLint("RestrictedApi") -fun NavBackStackEntry?.isCurrentRoute(cls: KClass): Boolean { - return this?.destination?.hierarchy?.any { it.hasRoute(route = cls) } == true -} - -val LocalNavController = - compositionLocalOf { error("NavController was not provided") } -val LocalIsAndroidTV = staticCompositionLocalOf { false } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt similarity index 66% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt index f870b441..509fb3a2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt @@ -1,54 +1,58 @@ -package com.zaneschepke.wireguardautotunnel.ui +package com.zaneschepke.wireguardautotunnel.ui.navigation import kotlinx.serialization.Serializable +@Serializable sealed class Route { + @Serializable data object TunnelsGraph : Route() + + @Serializable data object AutoTunnelGraph : Route() + + @Serializable data object SettingsGraph : Route() + + @Serializable data object SupportGraph : Route() + @Serializable data object Support : Route() - @Serializable data object Settings : Route() - - @Serializable data object AutoTunnel : Route() - - @Serializable data object AutoTunnelAdvanced : Route() - - @Serializable data object WifiDetectionMethod : Route() - - @Serializable data object LocationDisclosure : Route() - - @Serializable data object Appearance : Route() - - @Serializable data object Display : Route() - - @Serializable data object Language : Route() - - @Serializable data object Main : Route() - - @Serializable data class TunnelOptions(val id: Int) : Route() - @Serializable data object Lock : Route() @Serializable data object License : Route() - @Serializable data class Config(val id: Int) : Route() + @Serializable data object Logs : Route() - @Serializable - data class SplitTunnel(val id: Int) : Route() { - companion object { - const val KEY_ID = "id" - } - } + @Serializable data object Appearance : Route() + + @Serializable data object Language : Route() + + @Serializable data object Display : Route() + + @Serializable data object Tunnels : Route() + + @Serializable data class TunnelOptions(val id: Int) : Route() + + @Serializable data class Config(val id: Int?) : Route() + + @Serializable data class SplitTunnel(val id: Int) : Route() @Serializable data class TunnelAutoTunnel(val id: Int) : Route() - @Serializable data object Logs : Route() - @Serializable data object Sort : Route() + @Serializable data object Settings : Route() + @Serializable data object TunnelMonitoring : Route() @Serializable data object SystemFeatures : Route() + @Serializable data object Dns : Route() + @Serializable data object ProxySettings : Route() - @Serializable data object Dns : Route() + @Serializable data object AutoTunnel : Route() + + @Serializable data object AdvancedAutoTunnel : Route() + + @Serializable data object WifiDetectionMethod : Route() + + @Serializable data object LocationDisclosure : Route() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt index 2cb83237..00b732c0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/BottomNavbar.kt @@ -1,107 +1,153 @@ package com.zaneschepke.wireguardautotunnel.ui.navigation.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.navigation.BottomNavItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree -import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot +import com.zaneschepke.wireguardautotunnel.util.extensions.debounce + +@Composable +fun NavHostController.getCurrentGraph(): State { + val navBackStackEntry by currentBackStackEntryAsState() + + return remember(navBackStackEntry) { + derivedStateOf { + val parentRouteString = navBackStackEntry?.destination?.parent?.route + when (parentRouteString) { + Route.TunnelsGraph::class.qualifiedName -> Route.TunnelsGraph + Route.AutoTunnelGraph::class.qualifiedName -> Route.AutoTunnelGraph + Route.SettingsGraph::class.qualifiedName -> Route.SettingsGraph + Route.SupportGraph::class.qualifiedName -> Route.SupportGraph + else -> null + } + } + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BottomNavbar(appUiState: AppUiState) { - val navController = LocalNavController.current - val navBackStackEntry by navController.currentBackStackEntryAsState() +fun BottomNavbar( + isAutoTunnelActive: Boolean, + navbarState: NavbarState, + navController: NavHostController, +) { + + val currentGraph by navController.getCurrentGraph() + val coroutineScope = rememberCoroutineScope() + + val navigateToDebounced = + remember<(Route) -> Unit> { + debounce(scope = coroutineScope, 150L) { route -> + navController.navigate(route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + } val items = listOf( BottomNavItem( name = stringResource(R.string.tunnels), - route = Route.Main, icon = Icons.Rounded.Home, - onClick = { navController.goFromRoot(Route.Main) }, + onClick = { navigateToDebounced(Route.TunnelsGraph) }, + route = Route.TunnelsGraph, ), BottomNavItem( name = stringResource(R.string.auto_tunnel), - route = Route.AutoTunnel, icon = Icons.Rounded.Bolt, - onClick = { - val route = - if (appUiState.appState.isLocationDisclosureShown) { - Route.AutoTunnel - } else Route.LocationDisclosure - navController.goFromRoot(route) - }, - active = appUiState.isAutoTunnelActive, + onClick = { navigateToDebounced(Route.AutoTunnelGraph) }, + route = Route.AutoTunnelGraph, + active = isAutoTunnelActive, ), BottomNavItem( name = stringResource(R.string.settings), - route = Route.Settings, icon = Icons.Rounded.Settings, - onClick = { navController.goFromRoot(Route.Settings) }, + onClick = { navigateToDebounced(Route.SettingsGraph) }, + route = Route.SettingsGraph, ), BottomNavItem( name = stringResource(R.string.support), - route = Route.Support, icon = Icons.Rounded.QuestionMark, - onClick = { navController.goFromRoot(Route.Support) }, + onClick = { navigateToDebounced(Route.SupportGraph) }, + route = Route.SupportGraph, ), ) - NavigationBar(containerColor = MaterialTheme.colorScheme.surface) { - items.forEach { item -> - val isSelected = navBackStackEntry.isCurrentRoute(item.route::class) - val interactionSource = remember { MutableInteractionSource() } - - NavigationBarItem( - icon = { - if (item.active) { - BadgedBox( - badge = { - Badge( - modifier = Modifier.offset(x = 8.dp, y = (-8).dp).size(6.dp), - containerColor = SilverTree, - ) - } - ) { - Icon(imageVector = item.icon, contentDescription = item.name) - } - } else { - Icon(imageVector = item.icon, contentDescription = item.name) + if (!navbarState.removeBottom) { + NavigationBar(containerColor = MaterialTheme.colorScheme.surface) { + AnimatedVisibility( + visible = navbarState.showBottomItems, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEach { item -> + val interactionSource = remember { MutableInteractionSource() } + NavigationBarItem( + icon = { + if (item.active) { + BadgedBox( + badge = { + Badge( + modifier = + Modifier.offset(x = 8.dp, y = (-8).dp) + .size(6.dp), + containerColor = SilverTree, + ) + } + ) { + Icon( + imageVector = item.icon, + contentDescription = item.name, + ) + } + } else { + Icon(imageVector = item.icon, contentDescription = item.name) + } + }, + onClick = item.onClick, + selected = currentGraph == item.route, + enabled = true, + label = null, + alwaysShowLabel = false, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onBackground, + indicatorColor = Color.Transparent, + ), + interactionSource = interactionSource, + ) } - }, - onClick = item.onClick, - selected = isSelected, - enabled = true, - label = null, - alwaysShowLabel = false, - colors = - NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onBackground, - indicatorColor = Color.Transparent, - ), - interactionSource = interactionSource, - ) + } + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/DynamicTopAppBar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/DynamicTopAppBar.kt index 8c172080..6c762eb8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/DynamicTopAppBar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/DynamicTopAppBar.kt @@ -1,6 +1,5 @@ package com.zaneschepke.wireguardautotunnel.ui.navigation.components -import androidx.compose.animation.* import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api @@ -10,32 +9,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.ui.theme.LockedDownBannerHeight @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DynamicTopAppBar(navBarState: NavBarState, modifier: Modifier = Modifier) { +fun DynamicTopAppBar(navBarState: NavbarState, modifier: Modifier = Modifier) { TopAppBar( modifier = modifier.padding(top = LockedDownBannerHeight), colors = TopAppBarDefaults.topAppBarColors().copy(Color.Transparent), title = { - AnimatedVisibility( - visible = navBarState.showTop, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - Box(modifier = Modifier.padding(start = 10.dp)) { navBarState.topTitle?.invoke() } - } + Box(modifier = Modifier.padding(start = 10.dp)) { navBarState.topTitle?.invoke() } }, actions = { - AnimatedVisibility( - visible = navBarState.showTop, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - Box(modifier = Modifier.padding(end = 10.dp)) { navBarState.topTrailing?.invoke() } - } + Box(modifier = Modifier.padding(end = 10.dp)) { navBarState.topTrailing?.invoke() } }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt deleted file mode 100644 index 2ac6f28b..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentNavBackStackEntryAsNavBarState.kt +++ /dev/null @@ -1,335 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.navigation.components - -import android.os.Build -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController -import androidx.navigation.toRoute -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState -import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent -import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent - -@Composable -fun currentNavBackStackEntryAsNavBarState( - navController: NavController, - backStackEntry: NavBackStackEntry?, - viewModel: AppViewModel, - uiState: AppUiState, - appViewState: AppViewState, -): State { - val context = LocalContext.current - - fun isActiveSelected() = - uiState.activeTunnels.any { active -> - appViewState.selectedTunnels.any { it.id == active.key.id } - } - - @Composable - fun ActionIconButton(icon: ImageVector, labelRes: Int, onClick: () -> Unit) { - IconButton(onClick = onClick) { - Icon( - icon, - contentDescription = stringResource(labelRes), - modifier = Modifier.size(iconSize), - ) - } - } - - @Composable - fun TunnelActionBar() { - val selectedCount = appViewState.selectedTunnels.size - val showDelete = !isActiveSelected() - - Row { - if (selectedCount == 0) { - val showSort = remember(uiState.tunnels) { uiState.tunnels.size > 1 } - if (showSort) - ActionIconButton(Icons.AutoMirrored.Rounded.Sort, R.string.sort) { - navController.navigate(Route.Sort) - } - ActionIconButton(Icons.Rounded.Add, R.string.add_tunnel) { - viewModel.handleEvent( - AppEvent.SetBottomSheet(AppViewState.BottomSheet.IMPORT_TUNNELS) - ) - } - return@Row - } - ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) { - viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels) - } - // due to permissions, and SAF issues on TV, not support less than Android 10 on - // Android TV for file exports - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ActionIconButton(Icons.Rounded.Download, R.string.download) { - viewModel.handleEvent( - AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS) - ) - } - } - - if (selectedCount == 1) { - ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) { - viewModel.handleEvent(AppEvent.CopySelectedTunnel) - } - } - - if (showDelete) { - ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) { - viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE)) - } - } - } - } - - return produceState( - initialValue = NavBarState(), - key1 = backStackEntry, - key2 = uiState, - key3 = appViewState, - ) { - value = - when { - backStackEntry.isCurrentRoute(Route.Main::class) -> { - NavBarState( - topTitle = { Text(stringResource(R.string.tunnels)) }, - topTrailing = { TunnelActionBar() }, - route = Route.Main, - ) - } - - backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> { - - NavBarState( - topTitle = { Text(stringResource(R.string.auto_tunnel)) }, - route = Route.AutoTunnel, - ) - } - - backStackEntry.isCurrentRoute(Route.Logs::class) -> { - NavBarState( - showBottom = false, - topTitle = { Text(stringResource(R.string.logs)) }, - topTrailing = { - ActionIconButton(Icons.Rounded.Menu, R.string.quick_actions) { - viewModel.handleEvent( - AppEvent.SetBottomSheet(AppViewState.BottomSheet.LOGS) - ) - } - }, - route = Route.Logs, - ) - } - - backStackEntry.isCurrentRoute(Route.Settings::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.settings)) }, - route = Route.Settings, - topTrailing = { - ActionIconButton( - Icons.Rounded.SettingsBackupRestore, - R.string.quick_actions, - ) { - viewModel.handleEvent( - AppEvent.SetBottomSheet( - AppViewState.BottomSheet.BACKUP_AND_RESTORE - ) - ) - } - }, - ) - - backStackEntry.isCurrentRoute(Route.Appearance::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.appearance)) }, - route = Route.Appearance, - ) - - backStackEntry.isCurrentRoute(Route.Language::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.language)) }, - route = Route.Language, - ) - - backStackEntry.isCurrentRoute(Route.Display::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.display_theme)) }, - route = Route.Display, - ) - - backStackEntry.isCurrentRoute(Route.TunnelMonitoring::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.tunnel_monitoring)) }, - route = Route.TunnelMonitoring, - ) - - backStackEntry.isCurrentRoute(Route.ProxySettings::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.proxy_settings)) }, - route = Route.ProxySettings, - topTrailing = { - ActionIconButton(Icons.Rounded.Save, R.string.save) { - viewModel.handleEvent(AppEvent.InvokeScreenAction) - } - }, - ) - - backStackEntry.isCurrentRoute(Route.WifiDetectionMethod::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.wifi_detection_method)) }, - route = Route.WifiDetectionMethod, - ) - - backStackEntry.isCurrentRoute(Route.SystemFeatures::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.android_integrations)) }, - route = Route.SystemFeatures, - ) - - backStackEntry.isCurrentRoute(Route.Dns::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.dns_settings)) }, - route = Route.Dns, - ) - - backStackEntry.isCurrentRoute(Route.Support::class) -> - NavBarState( - topTitle = { Text(stringResource(R.string.support)) }, - route = Route.Support, - ) - - backStackEntry.isCurrentRoute(Route.Sort::class) -> { - NavBarState( - showTop = true, - showBottom = true, - topTitle = { Text(stringResource(R.string.sort)) }, - topTrailing = { - Row { - ActionIconButton(Icons.Rounded.SortByAlpha, R.string.sort) { - viewModel.handleUiEvent(UiEvent.SortTunnels) - } - ActionIconButton(Icons.Rounded.Save, R.string.save) { - viewModel.handleEvent(AppEvent.InvokeScreenAction) - } - } - }, - route = Route.Sort, - ) - } - - backStackEntry.isCurrentRoute(Route.License::class) -> { - NavBarState( - showTop = true, - showBottom = true, - topTitle = { Text(stringResource(R.string.licenses)) }, - route = Route.License, - ) - } - - backStackEntry.isCurrentRoute(Route.AutoTunnelAdvanced::class) -> - NavBarState( - showTop = true, - showBottom = true, - topTitle = { Text(stringResource(R.string.advanced_settings)) }, - route = Route.AutoTunnelAdvanced, - ) - - backStackEntry.isCurrentRoute(Route.TunnelOptions::class) -> { - val args = backStackEntry?.toRoute() - val tunnel = uiState.tunnels.find { it.id == args?.id } - - NavBarState( - showTop = true, - showBottom = true, - topTitle = { tunnel?.name?.let { Text(it) } }, - topTrailing = { - Row { - ActionIconButton(Icons.Rounded.QrCode2, R.string.show_qr) { - tunnel?.id?.let { - viewModel.handleEvent( - AppEvent.SetShowModal(AppViewState.ModalType.QR) - ) - } - } - ActionIconButton(Icons.Rounded.Edit, R.string.edit_tunnel) { - tunnel?.id?.let { navController.navigate(Route.Config(it)) } - } - } - }, - route = args?.let { Route.TunnelOptions(it.id) }, - ) - } - - backStackEntry.isCurrentRoute(Route.SplitTunnel::class) -> { - val args = backStackEntry?.toRoute() - val name = uiState.tunnels.find { it.id == args?.id }?.name - - NavBarState( - showTop = true, - showBottom = true, - topTitle = { name?.let { Text(it) } }, - topTrailing = { - ActionIconButton(Icons.Rounded.Save, R.string.save) { - viewModel.handleEvent(AppEvent.InvokeScreenAction) - } - }, - route = args?.let { Route.SplitTunnel(it.id) }, - ) - } - - backStackEntry.isCurrentRoute(Route.Config::class) -> { - val args = backStackEntry?.toRoute() - val name = - uiState.tunnels.find { it.id == args?.id }?.name - ?: context.getString(R.string.new_tunnel) - - NavBarState( - showTop = true, - showBottom = true, - topTitle = { Text(name) }, - topTrailing = { - ActionIconButton(Icons.Rounded.Save, R.string.save) { - viewModel.handleEvent(AppEvent.InvokeScreenAction) - } - }, - route = args?.let { Route.Config(it.id) }, - ) - } - - backStackEntry.isCurrentRoute(Route.TunnelAutoTunnel::class) -> { - val args = backStackEntry?.toRoute() - val name = uiState.tunnels.find { it.id == args?.id }?.name - - NavBarState( - showTop = true, - showBottom = true, - topTitle = { name?.let { Text(it) } }, - route = args?.let { Route.TunnelAutoTunnel(it.id) }, - ) - } - - else -> NavBarState(showTop = false, showBottom = false) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt index cc058628..07236a5c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt @@ -17,61 +17,75 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider import com.zaneschepke.wireguardautotunnel.ui.common.banner.WarningBanner import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem -import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems -import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.networkTunnelingItems +import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.wifiTunnelingItems +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings import com.zaneschepke.wireguardautotunnel.util.extensions.launchLocationServicesSettings -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @OptIn(ExperimentalPermissionsApi::class) @Composable -fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) { - val navController = LocalNavController.current +fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { val context = LocalContext.current + val sharedViewModel = LocalSharedVm.current + val navController = LocalNavController.current + val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + if (!autoTunnelState.stateInitialized) return + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.auto_tunnel)) }, + ) + ) + } - var currentText by remember { mutableStateOf("") } var showLocationDialog by remember { mutableStateOf(false) } val showLocationServicesWarning by remember( - uiState.connectivityState?.wifiState, - uiState.appSettings.trustedNetworkSSIDs, - uiState.appSettings.wifiDetectionMethod, + autoTunnelState.connectivityState?.wifiState, + autoTunnelState.generalSettings.trustedNetworkSSIDs, + autoTunnelState.generalSettings.wifiDetectionMethod, ) { derivedStateOf { - uiState.connectivityState?.wifiState?.locationServicesEnabled == false && - uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() && - uiState.appSettings.trustedNetworkSSIDs.isNotEmpty() + autoTunnelState.connectivityState?.wifiState?.locationServicesEnabled == false && + autoTunnelState.generalSettings.wifiDetectionMethod + .needsLocationPermissions() && + autoTunnelState.generalSettings.trustedNetworkSSIDs.isNotEmpty() } } val showLocationPermissionsWarning by remember( - uiState.connectivityState?.wifiState, - uiState.appSettings.trustedNetworkSSIDs, - uiState.appSettings.wifiDetectionMethod, + autoTunnelState.connectivityState?.wifiState, + autoTunnelState.generalSettings.trustedNetworkSSIDs, + autoTunnelState.generalSettings.wifiDetectionMethod, ) { derivedStateOf { - uiState.connectivityState?.wifiState?.locationPermissionsGranted == false && - uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() && - uiState.appSettings.trustedNetworkSSIDs.isNotEmpty() + autoTunnelState.connectivityState?.wifiState?.locationPermissionsGranted == false && + autoTunnelState.generalSettings.wifiDetectionMethod + .needsLocationPermissions() && + autoTunnelState.generalSettings.trustedNetworkSSIDs.isNotEmpty() } } - LaunchedEffect(uiState.appSettings.trustedNetworkSSIDs) { currentText = "" } - if (showLocationDialog) { InfoDialog( onAttest = { @@ -121,8 +135,8 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) { }, ) val (title, buttonText, icon) = - remember(uiState.isAutoTunnelActive) { - when (uiState.isAutoTunnelActive) { + remember(autoTunnelState.autoTunnelActive) { + when (autoTunnelState.autoTunnelActive) { true -> Triple( context.getString(R.string.auto_tunnel_running), @@ -144,7 +158,7 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) { leading = { Icon(icon, null) }, title = { Text(title) }, trailing = { - Button({ viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }) { + Button({ viewModel.toggleAutoTunnel() }) { Text( buttonText, fontWeight = FontWeight.Bold, @@ -159,16 +173,16 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) { ) ) SurfaceSelectionGroupButton( - items = WifiTunnelingItems(uiState, viewModel, currentText) { currentText = it } + items = wifiTunnelingItems(autoTunnelState, viewModel, navController) ) SectionDivider() - SurfaceSelectionGroupButton(items = NetworkTunnelingItems(uiState, viewModel)) + SurfaceSelectionGroupButton(items = networkTunnelingItems(autoTunnelState, viewModel)) SectionDivider() SurfaceSelectionGroupButton( items = listOf( AdvancedSettingsItem( - onClick = { navController.navigate(Route.AutoTunnelAdvanced) } + onClick = { navController.navigate(Route.AdvancedAutoTunnel) } ) ) ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt index de17caa6..329ca8e9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt @@ -12,18 +12,33 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable -fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) { +fun AutoTunnelAdvancedScreen(viewModel: AutoTunnelViewModel) { + val sharedViewModel = LocalSharedVm.current + val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.advanced_settings)) }, + ) + ) + } + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), @@ -44,11 +59,9 @@ fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) { ) }, leading = { Icon(Icons.Outlined.PauseCircle, null) }, - onSelected = { selected -> - viewModel.handleEvent(AppEvent.SetDebounceDelay(selected!!)) - }, + onSelected = { selected -> viewModel.setDebounceDelay(selected!!) }, options = (0..10).toList(), - currentValue = appUiState.appSettings.debounceDelaySeconds, + currentValue = autoTunnelState.generalSettings.debounceDelaySeconds, optionToString = { it?.toString() ?: stringResource(R.string._default) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt index 439b0a1c..12315fb0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.text.style.TextOverflow import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.AutoTunnelUiState +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable -fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List { +fun networkTunnelingItems( + autoTunnelState: AutoTunnelUiState, + viewModel: AutoTunnelViewModel, +): List { return listOf( SelectionItem( leading = { Icon(Icons.Outlined.SignalCellular4Bar, contentDescription = null) }, @@ -34,15 +36,15 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List, + trustedNetworks: Set, onDelete: (ssid: String) -> Unit, currentText: String, onSave: (ssid: String) -> Unit, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt index 4a7d8c67..b26cef41 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt @@ -3,46 +3,48 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.Filter1 +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material.icons.outlined.WifiFind import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.state.AutoTunnelUiState import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable -fun WifiTunnelingItems( - uiState: AppUiState, - viewModel: AppViewModel, - currentText: String, - onTextChange: (String) -> Unit, +fun wifiTunnelingItems( + autoTunnelState: AutoTunnelUiState, + viewModel: AutoTunnelViewModel, + navController: NavController, ): List { val context = LocalContext.current - val navController = LocalNavController.current val clipboardHelper = rememberClipboardHelper() + var currentText by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(autoTunnelState.generalSettings.trustedNetworkSSIDs) { currentText = "" } + val baseItems = listOf( SelectionItem( @@ -58,16 +60,16 @@ fun WifiTunnelingItems( }, trailing = { ScaledSwitch( - enabled = !uiState.appSettings.isAlwaysOnVpnEnabled, - checked = uiState.appSettings.isTunnelOnWifiEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) }, + enabled = !autoTunnelState.generalSettings.isAlwaysOnVpnEnabled, + checked = autoTunnelState.generalSettings.isTunnelOnWifiEnabled, + onClick = { viewModel.setAutoTunnelOnWifiEnabled(it) }, ) }, description = { val wifiInfo by - remember(uiState.connectivityState) { + remember(autoTunnelState.connectivityState) { derivedStateOf { - uiState.connectivityState + autoTunnelState.connectivityState ?.wifiState ?.takeIf { it.connected } .let { Pair(it?.ssid, it?.securityType) } @@ -101,11 +103,15 @@ fun WifiTunnelingItems( } } }, - onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) }, + onClick = { + viewModel.setAutoTunnelOnWifiEnabled( + !autoTunnelState.generalSettings.isTunnelOnWifiEnabled + ) + }, ) ) - return if (uiState.appSettings.isTunnelOnWifiEnabled) { + return if (autoTunnelState.generalSettings.isTunnelOnWifiEnabled) { baseItems + listOf( SelectionItem( @@ -123,7 +129,9 @@ fun WifiTunnelingItems( Text( stringResource( R.string.current_template, - uiState.appSettings.wifiDetectionMethod.asTitleString(context), + autoTunnelState.generalSettings.wifiDetectionMethod.asTitleString( + context + ), ), style = MaterialTheme.typography.bodySmall.copy( @@ -155,11 +163,15 @@ fun WifiTunnelingItems( }, trailing = { ScaledSwitch( - checked = uiState.appSettings.isWildcardsEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) }, + checked = autoTunnelState.generalSettings.isWildcardsEnabled, + onClick = { viewModel.setWildcardsEnabled(it) }, + ) + }, + onClick = { + viewModel.setWildcardsEnabled( + !autoTunnelState.generalSettings.isWildcardsEnabled ) }, - onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) }, ), SelectionItem( title = { @@ -195,41 +207,18 @@ fun WifiTunnelingItems( }, description = { TrustedNetworkTextBox( - uiState.appSettings.trustedNetworkSSIDs, - onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) }, + autoTunnelState.generalSettings.trustedNetworkSSIDs, + onDelete = { viewModel.removeTrustedNetworkName(it) }, currentText = currentText, - onSave = { ssid -> - viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid)) - }, - onValueChange = onTextChange, + onSave = { ssid -> viewModel.saveTrustedNetworkName(ssid) }, + onValueChange = { currentText = it }, supporting = { - if (uiState.appSettings.isWildcardsEnabled) WildcardsLabel() + if (autoTunnelState.generalSettings.isWildcardsEnabled) + WildcardsLabel() }, ) }, ), - SelectionItem( - leading = { Icon(Icons.Outlined.VpnKeyOff, contentDescription = null) }, - title = { - Text( - stringResource(R.string.kill_switch_off), - style = - MaterialTheme.typography.bodyMedium.copy( - MaterialTheme.colorScheme.onSurface - ), - ) - }, - trailing = { - ScaledSwitch( - enabled = uiState.appSettings.isVpnKillSwitchEnabled, - checked = uiState.appSettings.isDisableKillSwitchOnTrustedEnabled, - onClick = { - viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) - }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) }, - ), ) } else { baseItems diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt index 5ea7102d..8d9aa02a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt @@ -4,34 +4,53 @@ 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.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.extensions.asDescriptionString import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable -fun WifiDetectionMethodScreen(uiState: AppUiState, viewModel: AppViewModel) { +fun WifiDetectionMethodScreen(viewModel: AutoTunnelViewModel) { val context = LocalContext.current + val sharedViewModel = LocalSharedVm.current + + val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(stringResource(R.string.wifi_detection_method)) }, + showBottomItems = true, + ) + ) + } + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), modifier = Modifier.fillMaxSize().padding(top = 24.dp).padding(horizontal = 24.dp), ) { - enumValues().forEach { + enumValues().forEach { val title = it.asTitleString(context) val description = it.asDescriptionString(context) IconSurfaceButton( title = title, - onClick = { viewModel.handleEvent(AppEvent.SetDetectionMethod(it)) }, - selected = uiState.appSettings.wifiDetectionMethod == it, + onClick = { sharedViewModel.setWifiDetectionMethod(it) }, + selected = autoTunnelState.generalSettings.wifiDetectionMethod == it, description = description, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/LocationDisclosureScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/LocationDisclosureScreen.kt index 6ed11f92..1fc14533 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/LocationDisclosureScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/LocationDisclosureScreen.kt @@ -9,17 +9,23 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.LocationDisclosureHeader import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.appSettingsItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.skipItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable -fun LocationDisclosureScreen(viewModel: AppViewModel) { +fun LocationDisclosureScreen(viewModel: AutoTunnelViewModel) { + val navController = LocalNavController.current + val sharedViewModel = LocalSharedVm.current - LaunchedEffect(Unit) { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) } + LaunchedEffect(Unit) { sharedViewModel.updateNavbarState(NavbarState(showBottomItems = true)) } + + LaunchedEffect(Unit) { viewModel.setLocationDisclosureShown() } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -28,6 +34,6 @@ fun LocationDisclosureScreen(viewModel: AppViewModel) { ) { LocationDisclosureHeader() SurfaceSelectionGroupButton(items = listOf(appSettingsItem())) - SurfaceSelectionGroupButton(items = listOf(skipItem())) + SurfaceSelectionGroupButton(items = listOf(skipItem(navController))) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/AppSettingsItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/AppSettingsItem.kt index 9edd06ee..c3fba946 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/AppSettingsItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/AppSettingsItem.kt @@ -3,14 +3,14 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.com import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings @Composable @@ -20,9 +20,9 @@ fun appSettingsItem(): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.LocationOn, contentDescription = null) }, title = { - Text( - text = stringResource(R.string.launch_app_settings), - style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface), + SelectionItemLabel( + stringResource(R.string.launch_app_settings), + labelType = SelectionLabelType.TITLE, ) }, trailing = { ForwardButton { context.launchAppSettings() } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/LocationDisclosureHeader.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/LocationDisclosureHeader.kt index 668d1612..8a8fd07a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/LocationDisclosureHeader.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/LocationDisclosureHeader.kt @@ -20,14 +20,14 @@ fun LocationDisclosureHeader() { Icon( imageVector = icon, contentDescription = icon.name, - modifier = Modifier.padding(30.dp).size(128.dp), + modifier = Modifier.padding(24.dp).size(100.dp), ) Text( text = stringResource(R.string.prominent_background_location_title), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), ) Text( text = stringResource(R.string.prominent_background_location_message), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/SkipItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/SkipItem.kt index 3095cfed..0567d5a3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/SkipItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/disclosure/components/SkipItem.kt @@ -1,27 +1,22 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun skipItem(): SelectionItem { - val navController = LocalNavController.current +fun skipItem(navController: NavController): SelectionItem { return SelectionItem( title = { - Text( - text = stringResource(R.string.skip), - style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface), - ) + SelectionItemLabel(stringResource(R.string.skip), labelType = SelectionLabelType.TITLE) }, - trailing = { ForwardButton { navController.goFromRoot(Route.AutoTunnel) } }, - onClick = { navController.goFromRoot(Route.AutoTunnel) }, + trailing = { ForwardButton { navController.navigate(Route.AutoTunnelGraph) } }, + onClick = { navController.navigate(Route.AutoTunnelGraph) }, ) } 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 deleted file mode 100644 index ce1c5b9f..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main - -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog -import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper -import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ExportTunnelsBottomSheet -import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet -import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList -import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent - -@Composable -fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) { - val navController = LocalNavController.current - val clipboard = rememberClipboardHelper() - - var showUrlImportDialog by remember { mutableStateOf(false) } - - val tunnelFileImportResultLauncher = - rememberFileImportLauncherForResult( - onNoFileExplorer = { - viewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.error_no_file_explorer) - ) - ) - }, - onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) }, - ) - - val scanLauncher = - rememberLauncherForActivityResult( - contract = ScanContract(), - onResult = { result -> - if (result != null && result.contents.isNotEmpty()) - viewModel.handleEvent(AppEvent.ImportTunnelFromQrCode(result.contents)) - }, - ) - - val requestPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted - -> - if (!isGranted) { - viewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.camera_permission_required) - ) - ) - return@rememberLauncherForActivityResult - } - scanLauncher.launch( - ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false) - ) - } - - if (appViewState.showModal == AppViewState.ModalType.DELETE) { - InfoDialog( - onDismiss = { - viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE)) - }, - onAttest = { - viewModel.handleEvent(AppEvent.DeleteSelectedTunnels) - viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE)) - }, - title = { Text(text = stringResource(R.string.delete_tunnel)) }, - body = { Text(text = stringResource(R.string.delete_tunnel_message)) }, - confirmText = { Text(text = stringResource(R.string.yes)) }, - ) - } - - when (appViewState.bottomSheet) { - AppViewState.BottomSheet.EXPORT_TUNNELS -> { - ExportTunnelsBottomSheet(viewModel) - } - AppViewState.BottomSheet.IMPORT_TUNNELS -> { - TunnelImportSheet( - onDismiss = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) - }, - onFileClick = { - tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) - }, - onQrClick = { - requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) - }, - onClipboardClick = { - clipboard.paste { result -> - if (result != null) - viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(result)) - } - }, - onManualImportClick = { - navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) - }, - onUrlClick = { showUrlImportDialog = true }, - ) - } - else -> Unit - } - - if (showUrlImportDialog) { - UrlImportDialog( - onDismiss = { showUrlImportDialog = false }, - onConfirm = { url -> - viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url)) - showUrlImportDialog = false - }, - ) - } - - TunnelList( - appUiState = appUiState, - selectedTunnels = appViewState.selectedTunnels, - onToggleTunnel = { tunnel, checked -> - if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) - else viewModel.handleEvent(AppEvent.StopTunnel(tunnel)) - }, - modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), - viewModel = viewModel, - ) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/TunnelAutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/TunnelAutoTunnelScreen.kt deleted file mode 100644 index 7abab837..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/TunnelAutoTunnelScreen.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem -import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.PingRestartItem -import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem -import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.ethernetTunnelItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel - -@Composable -fun TunnelAutoTunnelScreen( - tunnelConf: TunnelConf, - appSettings: AppSettings, - viewModel: AppViewModel, -) { - var currentText by remember { mutableStateOf("") } - - LaunchedEffect(tunnelConf.tunnelNetworks) { currentText = "" } - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), - modifier = - Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(vertical = 24.dp) - .padding(horizontal = 12.dp), - ) { - SurfaceSelectionGroupButton( - items = - buildList { - if (appSettings.isPingEnabled) { - add(PingRestartItem(tunnelConf, viewModel)) - } - add(MobileDataTunnelItem(tunnelConf, viewModel)) - add(ethernetTunnelItem(tunnelConf, viewModel)) - add( - WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) { - currentText = it - } - ) - } - ) - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt deleted file mode 100644 index 77c2c805..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.common.SecureScreenFromRecording -import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.AddPeerButton -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent - -@Composable -fun ConfigScreen( - tunnelConf: TunnelConf?, - appUiState: AppUiState, - appViewModel: AppViewModel, - viewModel: ConfigViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val keyboardController = LocalSoftwareKeyboardController.current - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - var save by remember { mutableStateOf(false) } - - val isTunnelNameTaken by - remember(uiState.tunnelName, appUiState.tunnels) { - derivedStateOf { - appUiState.tunnels - .filter { it.id != tunnelConf?.id } - .any { it.name == uiState.tunnelName } - } - } - - SecureScreenFromRecording() - - LaunchedEffect(Unit) { - // set callback for navbar to invoke save - appViewModel.handleEvent( - AppEvent.SetScreenAction { - keyboardController?.hide() - if (!isTunnelNameTaken) { - save = true - } - } - ) - } - - LaunchedEffect(tunnelConf) { viewModel.initFromTunnel(tunnelConf) } - - // TODO improve error messages - LaunchedEffect(save) { - if (save) { - try { - appViewModel.handleEvent( - AppEvent.SaveTunnel( - uiState.configProxy.buildTunnelConfFromState(uiState.tunnelName, tunnelConf) - ) - ) - appViewModel.handleEvent( - AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)) - ) - appViewModel.handleEvent(AppEvent.PopBackStack(true)) - } catch (e: Exception) { - val message = e.message ?: context.resources.getString(R.string.unknown_error) - appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.DynamicString(message))) - } finally { - save = false - } - } - } - - if (uiState.showAuthPrompt) { - AuthorizationPrompt( - onSuccess = { - viewModel.toggleShowAuthPrompt() - viewModel.onAuthenticated() - }, - onError = { - viewModel.toggleShowAuthPrompt() - appViewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.error_authentication_failed) - ) - ) - }, - onFailure = { - viewModel.toggleShowAuthPrompt() - appViewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.error_authorization_failed) - ) - ) - }, - ) - } - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), - modifier = - Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(top = 12.dp, bottom = 24.dp) - .padding(horizontal = 12.dp), - ) { - InterfaceSection(isTunnelNameTaken, uiState, viewModel) - PeersSection(uiState, viewModel) - AddPeerButton(viewModel) - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt deleted file mode 100644 index 4e411d08..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config - -import androidx.lifecycle.ViewModel -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -@HiltViewModel -class ConfigViewModel @Inject constructor() : ViewModel() { - - private val _uiState = MutableStateFlow(ConfigUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun initFromTunnel(tunnelConf: TunnelConf?) { - if (tunnelConf == null) return - _uiState.update { - val proxy = ConfigProxy.from(tunnelConf.toAmConfig()) - it.copy( - tunnelName = tunnelConf.name, - configProxy = proxy, - showScripts = proxy.hasScripts(), - showAmneziaValues = proxy.`interface`.junkPacketCount.isNotBlank(), - isAuthenticated = false, - ) - } - } - - fun updateTunnelName(name: String) { - _uiState.update { it.copy(tunnelName = name) } - } - - fun updateInterface(newInterface: InterfaceProxy) { - _uiState.update { it.copy(configProxy = it.configProxy.copy(`interface` = newInterface)) } - } - - fun toggleAmneziaValues() { - _uiState.update { it.copy(showAmneziaValues = !it.showAmneziaValues) } - } - - fun toggleScripts() { - _uiState.update { it.copy(showScripts = !it.showScripts) } - } - - fun toggleAmneziaCompatibility() { - val (show, `interface`) = - with(_uiState.value.configProxy) { - if (`interface`.isAmneziaCompatibilityModeSet()) { - Pair(false, `interface`.resetAmneziaProperties()) - } else { - Pair(true, `interface`.toAmneziaCompatibilityConfig()) - } - } - _uiState.update { - it.copy( - showAmneziaValues = show, - configProxy = it.configProxy.copy(`interface` = `interface`), - ) - } - } - - fun addPeer() { - _uiState.update { currentState -> - currentState.copy( - configProxy = - currentState.configProxy.copy( - peers = currentState.configProxy.peers + PeerProxy() - ) - ) - } - } - - fun removePeer(index: Int) { - _uiState.update { currentState -> - currentState.copy( - configProxy = - currentState.configProxy.copy( - peers = - currentState.configProxy.peers.toMutableList().apply { removeAt(index) } - ) - ) - } - } - - fun updatePeer(index: Int, peer: PeerProxy) { - _uiState.update { currentState -> - currentState.copy( - configProxy = - currentState.configProxy.copy( - peers = - currentState.configProxy.peers.toMutableList().apply { - set(index, peer) - } - ) - ) - } - } - - fun toggleLanExclusion(index: Int) { - val peer = _uiState.value.configProxy.peers[index] - val updated = if (peer.isLanExcluded()) peer.includeLan() else peer.excludeLan() - updatePeer(index, updated) - } - - fun onAuthenticated() { - _uiState.update { it.copy(isAuthenticated = true) } - } - - fun toggleShowAuthPrompt() { - _uiState.update { it.copy(showAuthPrompt = !it.showAuthPrompt) } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt deleted file mode 100644 index c4845417..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState -import java.util.* - -@Composable -fun InterfaceSection( - isTunnelNameTaken: Boolean, - uiState: ConfigUiState, - viewModel: ConfigViewModel, -) { - var isDropDownExpanded by remember { mutableStateOf(false) } - val isAmneziaCompatibilitySet = - remember(uiState.configProxy.`interface`) { - uiState.configProxy.`interface`.isAmneziaCompatibilityModeSet() - } - - Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(horizontal = 16.dp).focusGroup(), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - GroupLabel(stringResource(R.string.interface_)) - InterfaceDropdown( - expanded = isDropDownExpanded, - onExpandedChange = { isDropDownExpanded = it }, - showScripts = uiState.showScripts, - showAmneziaValues = uiState.showAmneziaValues, - isAmneziaCompatibilitySet = isAmneziaCompatibilitySet, - onToggleScripts = viewModel::toggleScripts, - onToggleAmneziaValues = viewModel::toggleAmneziaValues, - onToggleAmneziaCompatibility = viewModel::toggleAmneziaCompatibility, - ) - } - ConfigurationTextBox( - value = uiState.tunnelName, - onValueChange = viewModel::updateTunnelName, - label = stringResource(R.string.name), - isError = isTunnelNameTaken, - hint = - stringResource(R.string.hint_template, stringResource(R.string.tunnel_name)) - .lowercase(Locale.getDefault()), - modifier = Modifier.fillMaxWidth(), - ) - InterfaceFields( - interfaceState = uiState.configProxy.`interface`, - showAuthPrompt = { viewModel.toggleShowAuthPrompt() }, - showScripts = uiState.showScripts, - showAmneziaValues = uiState.showAmneziaValues, - onInterfaceChange = viewModel::updateInterface, - isAuthenticated = uiState.isAuthenticated, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt deleted file mode 100644 index 4203a7e2..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state - -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy - -data class ConfigUiState( - val tunnelName: String = "", - val configProxy: ConfigProxy = - ConfigProxy(`interface` = InterfaceProxy(), peers = listOf(PeerProxy())), - val showAmneziaValues: Boolean = false, - val showScripts: Boolean = false, - val isAuthenticated: Boolean = true, - val showAuthPrompt: Boolean = false, - val saveChanges: Boolean = false, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt deleted file mode 100644 index 3a0e5875..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components.SplitTunnelContent -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent - -@Composable -fun SplitTunnelScreen( - appViewModel: AppViewModel, - viewModel: SplitTunnelViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - appViewModel.handleEvent(AppEvent.SetScreenAction { viewModel.saveChanges() }) - } - - LaunchedEffect(uiState.success) { - if (uiState.success == true) { - appViewModel.handleEvent( - AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)) - ) - appViewModel.handleEvent(AppEvent.PopBackStack(true)) - } - } - if (uiState.loading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(30.dp), strokeWidth = 5.dp) - } - } else { - SplitTunnelContent( - uiState = uiState, - onSplitOptionChange = viewModel::updateSplitOption, - onAppSelectionToggle = viewModel::toggleAppSelection, - onQueryChange = viewModel::onSearchQuery, - ) - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt deleted file mode 100644 index 9fdd0c50..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel - -import android.content.Context -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import java.text.Collator -import java.util.* -import javax.inject.Inject -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -@HiltViewModel -class SplitTunnelViewModel -@Inject -constructor( - @ApplicationContext private val context: Context, - private val tunnelRepository: TunnelRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - - private val _uiState = MutableStateFlow(SplitTunnelUiState()) - val uiState: StateFlow = - _uiState.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000L), - initialValue = SplitTunnelUiState(), - ) - - private val tunnelId: Int? = savedStateHandle.get(Route.SplitTunnel.KEY_ID) - private var allTunneledApps: List> = emptyList() - - init { - tunnelId?.let { loadInitialState(it) } - } - - private fun loadInitialState(tunnelId: Int) = - viewModelScope.launch { - val tunnel = tunnelRepository.getById(tunnelId) ?: return@launch - val proxyInterface = InterfaceProxy.from(tunnel.toAmConfig().`interface`) - val splitOption = - when { - proxyInterface.excludedApplications.isNotEmpty() -> SplitOption.EXCLUDE - proxyInterface.includedApplications.isNotEmpty() -> SplitOption.INCLUDE - else -> SplitOption.ALL - } - - val packages = context.getAllInternetCapablePackages() - - val installedPackages = packages.map { it.packageName }.toSet() - - // Remove uninstalled apps - proxyInterface.includedApplications.retainAll { it in installedPackages } - proxyInterface.excludedApplications.retainAll { it in installedPackages } - - var configProxy = ConfigProxy.from(tunnel.toAmConfig()) - configProxy = configProxy.copy(`interface` = proxyInterface) - saveProxyConfig(configProxy, tunnel) - - val collator = Collator.getInstance(Locale.getDefault()) - val tunneledApps = - packages - .filter { it.applicationInfo != null } - .map { pack -> - val selected = - when (splitOption) { - SplitOption.INCLUDE -> - proxyInterface.includedApplications.contains(pack.packageName) - SplitOption.ALL -> false - SplitOption.EXCLUDE -> - proxyInterface.excludedApplications.contains(pack.packageName) - } - Pair( - TunnelApp( - name = - context.packageManager - .getApplicationLabel(pack.applicationInfo!!) - .toString(), - `package` = pack.packageName, - ), - selected, - ) - } - .sortedWith( - compareByDescending> { it.second } - .thenBy(collator) { it.first.name } - ) - - allTunneledApps = tunneledApps - - _uiState.update { - SplitTunnelUiState( - loading = false, - tunnelConf = tunnel, - tunneledApps = tunneledApps, - queriedApps = tunneledApps, - splitOption = splitOption, - ) - } - } - - fun onSearchQuery(query: String) { - val filteredApps = - if (query.isBlank()) { - _uiState.value.tunneledApps - } else { - _uiState.value.tunneledApps.filter { - it.first.name.contains(query, ignoreCase = true) || - it.first.`package`.contains(query, ignoreCase = true) - } - } - _uiState.update { it.copy(searchQuery = query, queriedApps = filteredApps) } - } - - fun updateSplitOption(newOption: SplitOption) { - _uiState.value = _uiState.value.copy(splitOption = newOption) - } - - fun toggleAppSelection(packageName: String) { - val currentState = _uiState.value - val updatedApps = - currentState.tunneledApps.map { (app, selected) -> - if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected) - } - val updatedQueryApps = - currentState.queriedApps.map { (app, selected) -> - if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected) - } - _uiState.value = - currentState.copy(tunneledApps = updatedApps, queriedApps = updatedQueryApps) - } - - fun saveChanges() = - viewModelScope.launch { - val state = _uiState.value - val tunnel = state.tunnelConf ?: return@launch - val configProxy = ConfigProxy.from(tunnel.toAmConfig()) - val updatedApps = state.tunneledApps - with(configProxy.`interface`) { - includedApplications.clear() - excludedApplications.clear() - when (state.splitOption) { - SplitOption.INCLUDE -> { - includedApplications.addAll( - updatedApps.filter { it.second }.map { it.first.`package` } - ) - } - SplitOption.EXCLUDE -> { - excludedApplications.addAll( - updatedApps.filter { it.second }.map { it.first.`package` } - ) - } - SplitOption.ALL -> Unit - } - } - saveProxyConfig(configProxy, tunnel) - _uiState.update { it.copy(success = true) } - } - - private suspend fun saveProxyConfig(proxy: ConfigProxy, tunnel: TunnelConf) { - val (wg, am) = proxy.buildConfigs() - tunnelRepository.save( - tunnel.copyWithCallback( - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - ) - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt deleted file mode 100644 index e29746b4..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components - -import android.content.pm.PackageManager -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Checkbox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp -import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize - -@Composable -fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) { - val context = LocalContext.current - val icon = - remember(appInfo.`package`) { - try { - context.packageManager.getApplicationIcon(appInfo.`package`) - } catch (e: PackageManager.NameNotFoundException) { - null - } - } - - SelectionItemButton( - leading = { - Image( - painter = rememberDrawablePainter(icon), - contentDescription = appInfo.name, - modifier = Modifier.padding(horizontal = 24.dp).size(iconSize), - ) - }, - buttonText = appInfo.name, - onClick = onToggle, - trailing = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle() }) - } - }, - ) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt deleted file mode 100644 index 571fa8b2..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state - -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf - -data class SplitTunnelUiState( - val loading: Boolean = true, - val tunnelConf: TunnelConf? = null, - val tunneledApps: SplitTunnelApps = emptyList(), - val queriedApps: SplitTunnelApps = emptyList(), - val splitOption: SplitOption = SplitOption.ALL, - val searchQuery: String = "", - val success: Boolean? = null, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/TunnelOptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/TunnelOptionsScreen.kt deleted file mode 100644 index 37e682fb..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/TunnelOptionsScreen.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider -import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.* -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AuthorizationPromptWrapper -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent - -@Composable -fun TunnelOptionsScreen( - tunnelConf: TunnelConf, - viewModel: AppViewModel, - appViewState: AppViewState, - appSettings: AppSettings, -) { - val isTv = LocalIsAndroidTV.current - - var showAuthPrompt by remember { mutableStateOf(!isTv) } - var isAuthorized by remember { mutableStateOf(isTv) } - - if (appViewState.showModal == AppViewState.ModalType.QR) { - - // Show authorization prompt if needed - if (showAuthPrompt) { - AuthorizationPromptWrapper( - onDismiss = { - showAuthPrompt = false - viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE)) - }, - onSuccess = { - showAuthPrompt = false - isAuthorized = true - }, - viewModel = viewModel, - ) - } - if (isAuthorized) { - QrCodeDialog( - tunnelConf = tunnelConf, - onDismiss = { - viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE)) - }, - ) - } - } - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), - modifier = - Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(top = 24.dp) - .padding(horizontal = 12.dp), - ) { - SurfaceSelectionGroupButton( - items = - listOf( - PrimaryTunnelItem(tunnelConf, viewModel), - AutoTunnelingItem(tunnelConf), - serverIpv4Item(tunnelConf, viewModel), - SplitTunnelingItem(tunnelConf), - ) - ) - if (appSettings.isPingEnabled) { - SectionDivider() - SurfaceSelectionGroupButton(items = listOf(pingConfigItem(tunnelConf, viewModel))) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pin/PinLockScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pin/PinLockScreen.kt index 8bc71511..6e3faa46 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pin/PinLockScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pin/PinLockScreen.kt @@ -1,28 +1,35 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.pin +import androidx.activity.compose.BackHandler import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import xyz.teamgravity.pin_lock_compose.PinLock +import xyz.teamgravity.pin_lock_compose.PinManager @Composable -fun PinLockScreen(viewModel: AppViewModel) { +fun PinLockScreen() { + val sharedViewModel = LocalSharedVm.current val navController = LocalNavController.current - val isTv = LocalIsAndroidTV.current + val pinAlreadyExists by rememberSaveable { mutableStateOf(PinManager.pinExists()) } + var pinCreated by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { sharedViewModel.updateNavbarState(NavbarState(showBottomItems = false)) } + PinLock( - title = { pinExists -> + title = { Text( color = MaterialTheme.colorScheme.onSurface, text = - if (pinExists) { + if (pinAlreadyExists || pinCreated) { stringResource(id = R.string.enter_pin) } else { stringResource(id = R.string.create_pin) @@ -32,29 +39,21 @@ fun PinLockScreen(viewModel: AppViewModel) { backgroundColor = MaterialTheme.colorScheme.surface, textColor = MaterialTheme.colorScheme.onSurface, onPinCorrect = { - // pin is correct, navigate or hide pin lock - if (isTv) { - navController.navigate(Route.Main) - } else { - val isPopped = navController.popBackStack() - if (!isPopped) { - navController.navigate(Route.Main) - } - } + sharedViewModel.authenticated() + navController.popBackStack() + navController.navigate(Route.TunnelsGraph) }, onPinIncorrect = { - // pin is incorrect, show error - viewModel.handleEvent( - AppEvent.ShowMessage(StringValue.StringResource(R.string.incorrect_pin)) - ) + sharedViewModel.showToast(StringValue.StringResource(R.string.incorrect_pin)) }, onPinCreated = { - // pin created for the first time, navigate or hide pin lock - viewModel.handleEvent( - AppEvent.ShowMessage(StringValue.StringResource(R.string.pin_created)) - ) - - viewModel.handleEvent(AppEvent.TogglePinLock) + pinCreated = true + sharedViewModel.showToast(StringValue.StringResource(R.string.pin_created)) + sharedViewModel.setPinLockEnabled(true) }, ) + BackHandler(enabled = (!pinAlreadyExists && !pinCreated)) { + PinManager.clearPin() + navController.navigate(Route.SettingsGraph) + } } 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 31ce294a..bb4c5a34 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 @@ -8,55 +8,79 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SettingsBackupRestore +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.* -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.compoents.BackendModeBottomSheet -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.compoents.AppModeBottomSheet +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel +import xyz.teamgravity.pin_lock_compose.PinManager @Composable -fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) { +fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) { val isTv = LocalIsAndroidTV.current - val focusManager = LocalFocusManager.current + val context = LocalContext.current val navController = LocalNavController.current - + val focusManager = LocalFocusManager.current + val sharedViewModel = LocalSharedVm.current val interactionSource = remember { MutableInteractionSource() } - val showBackupBottomSheet by - remember(appViewState.bottomSheet) { - derivedStateOf { - appViewState.bottomSheet == AppViewState.BottomSheet.BACKUP_AND_RESTORE - } - } - val showBottomSheet by - remember(appViewState.bottomSheet) { - derivedStateOf { appViewState.bottomSheet == AppViewState.BottomSheet.BACKEND } - } + val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + if (!settingsState.stateInitialized) return + + var showBackupSheet by rememberSaveable { mutableStateOf(false) } + var showAppModeSheet by rememberSaveable { mutableStateOf(false) } + val showProxySettings by - remember(uiState.appSettings.appMode) { + remember(settingsState.settings.appMode) { derivedStateOf { - when (uiState.appSettings.appMode) { + when (settingsState.settings.appMode) { AppMode.PROXY -> true else -> false } } } - if (showBackupBottomSheet) BackupBottomSheet(viewModel) - if (showBottomSheet) BackendModeBottomSheet(uiState.appSettings, viewModel) + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.settings)) }, + topTrailing = { + ActionIconButton(Icons.Rounded.SettingsBackupRestore, R.string.quick_actions) { + showBackupSheet = true + } + }, + ) + ) + } + + if (showBackupSheet) BackupBottomSheet() { showBackupSheet = false } + if (showAppModeSheet) + AppModeBottomSheet(sharedViewModel::setAppMode, settingsState.settings.appMode) { + showAppModeSheet = false + } Column( horizontalAlignment = Alignment.Start, @@ -78,30 +102,51 @@ fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: A } ), ) { - SurfaceSelectionGroupButton(buildList { add(backendModeItem(uiState, viewModel)) }) + SurfaceSelectionGroupButton( + buildList { + add(backendModeItem(settingsState.settings.appMode) { showAppModeSheet = true }) + } + ) SectionDivider() SurfaceSelectionGroupButton( items = buildList { - if (uiState.appSettings.appMode == AppMode.LOCK_DOWN) { - add(lanTrafficItem(uiState, viewModel)) + if (settingsState.settings.appMode == AppMode.LOCK_DOWN) { + add( + lanTrafficItem(settingsState.settings.isLanOnKillSwitchEnabled) { + viewModel.setLanKillSwitchEnabled(it) + } + ) } - add(tunnelMonitoringItem()) - add(dnsSettingsItem()) + add(tunnelMonitoringItem(navController)) + add(dnsSettingsItem(navController)) // TODO changing these settings won't work in certain app states - if (showProxySettings) add(proxYSettingsItem()) + if (showProxySettings) add(proxYSettingsItem(navController)) } ) SectionDivider() - SurfaceSelectionGroupButton(listOf(systemFeaturesItem())) + SurfaceSelectionGroupButton(listOf(systemFeaturesItem(navController))) SectionDivider() SurfaceSelectionGroupButton( items = buildList { - add(appearanceItem()) - add(LocalLoggingItem(uiState, viewModel)) - if (uiState.appState.isLocalLogsEnabled) add(ReadLogsItem()) - add(PinLockItem(uiState, viewModel)) + add(appearanceItem(navController)) + add( + localLoggingItem(settingsState.isLocalLoggingEnabled) { + viewModel.setLocalLogging(it) + } + ) + if (settingsState.isLocalLoggingEnabled) add(readLogsItem(navController)) + add( + pinLockItem(settingsState.isPinLockEnabled) { enabled -> + if (enabled) { + PinManager.initialize(context) + navController.navigate(Route.Lock) + } else { + sharedViewModel.setPinLockEnabled(false) + } + } + ) } ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/AppearanceScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/AppearanceScreen.kt index 3eb71fe5..9f8c5f5b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/AppearanceScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/AppearanceScreen.kt @@ -4,27 +4,45 @@ 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.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.DisplayThemeItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.LanguageItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.NotificationsItem +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState @Composable fun AppearanceScreen() { + val sharedViewModel = LocalSharedVm.current + val navController = LocalNavController.current + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.appearance)) }, + ) + ) + } + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), ) { - SurfaceSelectionGroupButton(items = listOf(LanguageItem())) + SurfaceSelectionGroupButton(items = listOf(LanguageItem(navController))) SectionDivider() SurfaceSelectionGroupButton(items = listOf(NotificationsItem())) SectionDivider() - SurfaceSelectionGroupButton(items = listOf(DisplayThemeItem())) + SurfaceSelectionGroupButton(items = listOf(DisplayThemeItem(navController))) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/DisplayItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/DisplayItem.kt index 975428e8..b98890a9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/DisplayItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/DisplayItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun DisplayThemeItem(): SelectionItem { - val navController = LocalNavController.current +fun DisplayThemeItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Contrast, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/LanguageItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/LanguageItem.kt index 0fac633c..8659b127 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/LanguageItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/components/LanguageItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun LanguageItem(): SelectionItem { - val navController = LocalNavController.current +fun LanguageItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Translate, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt index 9ec4b942..4643c592 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/display/DisplayScreen.kt @@ -4,20 +4,37 @@ 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.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.ui.theme.Theme -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) { +fun DisplayScreen() { + + val sharedViewModel = LocalSharedVm.current + + val appState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.display_theme)) }, + ) + ) + } + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), @@ -35,8 +52,8 @@ fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) { } IconSurfaceButton( title = title, - onClick = { viewModel.handleEvent(AppEvent.SetTheme(it)) }, - selected = appUiState.appState.theme == it, + onClick = { sharedViewModel.setTheme(it) }, + selected = appState.theme == it, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt index 25040f81..423c0686 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/LanguageScreen.kt @@ -4,25 +4,41 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.AutomaticLanguageItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components.LanguageItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.LocaleUtil -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import java.text.Collator import java.util.* @Composable -fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) { - val collator = Collator.getInstance(Locale.getDefault()) - val isTv = LocalIsAndroidTV.current +fun LanguageScreen() { + val sharedViewModel = LocalSharedVm.current + val appState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.language)) }, + ) + ) + } + + val collator = Collator.getInstance(Locale.getDefault()) val locales = LocaleUtil.supportedLocales.map { val tag = it.replace("_", "-") @@ -34,14 +50,48 @@ fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) { locales.sortedWith(compareBy(collator) { it.getDisplayName(it) }).toList() } + val lazyListState = rememberLazyListState() + + val selectedIndex = + remember(appState.locale, sortedLocales) { + if (appState.locale == LocaleUtil.OPTION_PHONE_LANGUAGE) 0 + else { + val selectedLocale = Locale.forLanguageTag(appState.locale) + sortedLocales.indexOfFirst { + it.toLanguageTag() == selectedLocale.toLanguageTag() + } + 1 + } + } + + LaunchedEffect(selectedIndex) { + if (selectedIndex >= 0) { + lazyListState.scrollToItem(selectedIndex) + } + } + LazyColumn( + state = lazyListState, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, modifier = Modifier.padding(horizontal = 12.dp), ) { - item { AutomaticLanguageItem(appUiState, viewModel, isTv) } + item { + IconSurfaceButton( + stringResource(R.string.automatic), + { sharedViewModel.setLocale(LocaleUtil.OPTION_PHONE_LANGUAGE) }, + selected = appState.locale == LocaleUtil.OPTION_PHONE_LANGUAGE, + ) + } items(sortedLocales, key = { it }) { locale -> - LanguageItem(locale, appUiState, viewModel, isTv) + val buttonText = + locale.getDisplayName(locale).replaceFirstChar { + if (it.isLowerCase()) it.titlecase(locale) else it.toString() + } + IconSurfaceButton( + buttonText, + onClick = { sharedViewModel.setLocale(locale.toLanguageTag()) }, + selected = appState.locale == locale.toLanguageTag(), + ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/AutomaticLanguageItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/AutomaticLanguageItem.kt index 3ab460f8..8b2c6bfe 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/AutomaticLanguageItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/AutomaticLanguageItem.kt @@ -7,29 +7,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton import com.zaneschepke.wireguardautotunnel.ui.common.label.SelectedLabel -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.util.LocaleUtil -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun AutomaticLanguageItem(appUiState: AppUiState, viewModel: AppViewModel, isAndroidTv: Boolean) { +fun automaticLanguageItem(locale: String, onChange: (locale: String) -> Unit) { + val isTv = LocalIsAndroidTV.current Box(modifier = Modifier.padding(top = 24.dp)) { SelectionItemButton( buttonText = stringResource(R.string.automatic), - onClick = { - viewModel.handleEvent(AppEvent.SetLocale(LocaleUtil.OPTION_PHONE_LANGUAGE)) - }, + onClick = { onChange(LocaleUtil.OPTION_PHONE_LANGUAGE) }, trailing = { - with(appUiState.appState.locale) { - if (this == LocaleUtil.OPTION_PHONE_LANGUAGE || this == null) { - SelectedLabel() - } + if (locale == LocaleUtil.OPTION_PHONE_LANGUAGE) { + SelectedLabel() } }, - ripple = isAndroidTv, + ripple = isTv, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/LanguageItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/LanguageItem.kt index 7925e0c6..ab8cd643 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/LanguageItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/appearance/language/components/LanguageItem.kt @@ -1,20 +1,14 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.components import androidx.compose.runtime.Composable +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton import com.zaneschepke.wireguardautotunnel.ui.common.label.SelectedLabel -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import java.util.* @Composable -fun LanguageItem( - locale: Locale, - appUiState: AppUiState, - viewModel: AppViewModel, - isAndroidTv: Boolean, -) { +fun languageItem(currentLocale: String, locale: Locale, onChange: (String) -> Unit) { + val isTv = LocalIsAndroidTV.current SelectionItemButton( buttonText = locale.getDisplayLanguage(locale).replaceFirstChar { @@ -27,12 +21,12 @@ fun LanguageItem( } else { "" }, - onClick = { viewModel.handleEvent(AppEvent.SetLocale(locale.toLanguageTag())) }, + onClick = { onChange(locale.toLanguageTag()) }, trailing = { - if (locale.toLanguageTag() == appUiState.appState.locale) { + if (locale.toLanguageTag() == currentLocale) { SelectedLabel() } }, - ripple = isAndroidTv, + ripple = isTv, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AuthorizationPromptWrapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AuthorizationPromptWrapper.kt index e6ebd62c..34203427 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AuthorizationPromptWrapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AuthorizationPromptWrapper.kt @@ -1,37 +1,26 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun AuthorizationPromptWrapper( - onSuccess: () -> Unit, - onDismiss: () -> Unit, - viewModel: AppViewModel, -) { - val context = LocalContext.current - +fun AuthorizationPromptWrapper(onSuccess: () -> Unit, onDismiss: () -> Unit) { + val sharedViewModel = LocalSharedVm.current AuthorizationPrompt( onSuccess = { onSuccess() }, onError = { _ -> onDismiss() - viewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.error_authentication_failed) - ) + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.error_authentication_failed) ) }, onFailure = { onDismiss() - viewModel.handleEvent( - AppEvent.ShowMessage( - StringValue.StringResource(R.string.error_authorization_failed) - ) + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.error_authorization_failed) ) }, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt index b0d16d9a..a92e738b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt @@ -12,13 +12,10 @@ import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BackupBottomSheet(viewModel: AppViewModel) { +fun BackupBottomSheet(onDismiss: () -> Unit) { val context = LocalContext.current CustomBottomSheet( @@ -27,7 +24,7 @@ fun BackupBottomSheet(viewModel: AppViewModel) { ImageVector.vectorResource(R.drawable.database), stringResource(R.string.backup_application), onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() (context as? MainActivity)?.performBackup() }, ), @@ -35,12 +32,12 @@ fun BackupBottomSheet(viewModel: AppViewModel) { Icons.Outlined.Restore, stringResource(R.string.restore_application), onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() (context as? MainActivity)?.performRestore() }, ), ) ) { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LanTrafficItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LanTrafficItem.kt index cdab17dc..a01385f2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LanTrafficItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LanTrafficItem.kt @@ -10,12 +10,12 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun lanTrafficItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun lanTrafficItem( + isLanKillSwitchEnabled: Boolean, + onClick: (enable: Boolean) -> Unit, +): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Lan, contentDescription = null) }, title = { @@ -31,12 +31,7 @@ fun lanTrafficItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, - trailing = { - ScaledSwitch( - checked = uiState.appSettings.isLanOnKillSwitchEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) }, + trailing = { ScaledSwitch(checked = isLanKillSwitchEnabled, onClick = onClick) }, + onClick = { onClick(!isLanKillSwitchEnabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LocalLoggingItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LocalLoggingItem.kt index e7006e24..16fb7d97 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LocalLoggingItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/LocalLoggingItem.kt @@ -10,12 +10,12 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun LocalLoggingItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun localLoggingItem( + isLocalLoggingEnabled: Boolean, + onClick: (checked: Boolean) -> Unit, +): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.ViewHeadline, contentDescription = null) }, title = { @@ -27,12 +27,7 @@ fun LocalLoggingItem(uiState: AppUiState, viewModel: AppViewModel): SelectionIte SelectionLabelType.DESCRIPTION, ) }, - trailing = { - ScaledSwitch( - checked = uiState.appState.isLocalLogsEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleLocalLogging) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleLocalLogging) }, + trailing = { ScaledSwitch(checked = isLocalLoggingEnabled, onClick = onClick) }, + onClick = { onClick(!isLocalLoggingEnabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/PinLockItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/PinLockItem.kt index 112aa7c1..1877102d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/PinLockItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/PinLockItem.kt @@ -6,31 +6,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent -import xyz.teamgravity.pin_lock_compose.PinManager @Composable -fun PinLockItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { - val navController = LocalNavController.current - val context = LocalContext.current - - fun onPinLockToggle() { - if (uiState.appState.isPinLockEnabled) { - viewModel.handleEvent(AppEvent.TogglePinLock) - } else { - PinManager.initialize(context) - navController.navigate(Route.Lock) - } - } +fun pinLockItem(isPinLockEnabled: Boolean, onClick: (checked: Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Pin, contentDescription = null) }, @@ -41,12 +23,7 @@ fun PinLockItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - trailing = { - ScaledSwitch( - checked = uiState.appState.isPinLockEnabled, - onClick = { onPinLockToggle() }, - ) - }, - onClick = { onPinLockToggle() }, + trailing = { ScaledSwitch(checked = isPinLockEnabled, onClick = onClick) }, + onClick = { onClick(!isPinLockEnabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appearanceItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appearanceItem.kt index df58d181..5593d3e5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appearanceItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appearanceItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun appearanceItem(): SelectionItem { - val navController = LocalNavController.current +fun appearanceItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.AutoMirrored.Outlined.ViewQuilt, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/backendModeItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/backendModeItem.kt index 50141bd6..b2463341 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/backendModeItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/backendModeItem.kt @@ -9,17 +9,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun backendModeItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun backendModeItem(appMode: AppMode, onClick: () -> Unit): SelectionItem { val context = LocalContext.current return SelectionItem( leading = { Icon(ImageVector.vectorResource(R.drawable.sdk), contentDescription = null) }, @@ -31,15 +28,10 @@ fun backendModeItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem }, description = { SelectionItemLabel( - stringResource( - R.string.current_template, - uiState.appSettings.appMode.asTitleString(context), - ), + stringResource(R.string.current_template, appMode.asTitleString(context)), SelectionLabelType.DESCRIPTION, ) }, - onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.BACKEND)) - }, + onClick = onClick, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/dnsSettingsItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/dnsSettingsItem.kt index a4257137..b0eed072 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/dnsSettingsItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/dnsSettingsItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun dnsSettingsItem(): SelectionItem { - val navController = LocalNavController.current +fun dnsSettingsItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Dns, null) }, trailing = { ForwardButton { navController.navigate(Route.Dns) } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/proxySettingsItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/proxySettingsItem.kt index 843b6728..3936b19f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/proxySettingsItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/proxySettingsItem.kt @@ -7,15 +7,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun proxYSettingsItem(): SelectionItem { - val navController = LocalNavController.current +fun proxYSettingsItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(ImageVector.vectorResource(R.drawable.proxy), null) }, trailing = { ForwardButton { navController.navigate(Route.ProxySettings) } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/ReadLogsItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/readLogsItem.kt similarity index 83% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/ReadLogsItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/readLogsItem.kt index 3569827f..7e63afd0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/ReadLogsItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/readLogsItem.kt @@ -5,17 +5,16 @@ import androidx.compose.material.icons.filled.ViewTimeline import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun ReadLogsItem(): SelectionItem { - val navController = LocalNavController.current +fun readLogsItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Filled.ViewTimeline, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/systemFeaturesItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/systemFeaturesItem.kt index 9a74f45d..db4115d9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/systemFeaturesItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/systemFeaturesItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun systemFeaturesItem(): SelectionItem { - val navController = LocalNavController.current +fun systemFeaturesItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Android, null) }, trailing = { ForwardButton { navController.navigate(Route.SystemFeatures) } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt index 2eb6923b..00271efe 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt @@ -7,15 +7,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun tunnelMonitoringItem(): SelectionItem { - val navController = LocalNavController.current +fun tunnelMonitoringItem(navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.MonitorHeart, null) }, trailing = { ForwardButton { navController.navigate(Route.TunnelMonitoring) } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt index 05d01a1c..28aa8ba8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/dns/DnsSettingsScreen.kt @@ -14,22 +14,37 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel @Composable -fun DnsSettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { +fun DnsSettingsScreen(viewModel: SettingsViewModel) { val context = LocalContext.current + val sharedViewModel = LocalSharedVm.current + val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.dns_settings)) }, + ) + ) + } + Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), @@ -50,14 +65,12 @@ fun DnsSettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { ) }, leading = { Icon(Icons.Outlined.Dns, contentDescription = null) }, - currentValue = uiState.appSettings.dnsProtocol, - onSelected = { selected -> - selected?.let { viewModel.handleEvent(AppEvent.SetDnsProtocol(it)) } - }, + currentValue = settingsState.settings.dnsProtocol, + onSelected = { selected -> selected?.let { viewModel.setDnsProtocol(it) } }, options = DnsProtocol.entries, optionToString = { (it ?: DnsProtocol.SYSTEM).asString(context) }, ) - AnimatedVisibility(uiState.appSettings.dnsProtocol != DnsProtocol.SYSTEM) { + AnimatedVisibility(settingsState.settings.dnsProtocol != DnsProtocol.SYSTEM) { LabelledDropdown( title = { Text( @@ -70,11 +83,9 @@ fun DnsSettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { }, leading = { Icon(Icons.Outlined.Cloud, contentDescription = null) }, currentValue = - uiState.appSettings.dnsEndpoint?.let { DnsProvider.fromAddress(it) } + settingsState.settings.dnsEndpoint?.let { DnsProvider.fromAddress(it) } ?: DnsProvider.CLOUDFLARE, - onSelected = { selected -> - selected?.let { viewModel.handleEvent(AppEvent.SetDnsProvider(it)) } - }, + onSelected = { selected -> selected?.let { viewModel.setDnsProvider(it) } }, options = DnsProvider.entries, optionToString = { it?.name ?: DnsProvider.CLOUDFLARE.name }, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/LogsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/LogsScreen.kt index bdb6494b..d854709a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/LogsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/LogsScreen.kt @@ -4,38 +4,61 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.components.LogList import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.components.LogsBottomSheet -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.LoggerViewModel @Composable -fun LogsScreen(appViewState: AppViewState, viewModel: AppViewModel) { - val logs by viewModel.logs.collectAsStateWithLifecycle() +fun LogsScreen(viewModel: LoggerViewModel = hiltViewModel()) { + val sharedViewModel = LocalSharedVm.current + val loggerState by viewModel.container.stateFlow.collectAsStateWithLifecycle() val lazyColumnListState = rememberLazyListState() - var isAutoScrolling by remember { mutableStateOf(true) } - var lastScrollPosition by remember { mutableIntStateOf(0) } + var isAutoScrolling by rememberSaveable { mutableStateOf(true) } + var lastScrollPosition by rememberSaveable() { mutableIntStateOf(0) } + var showLogsSheet by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = false, + removeBottom = true, + topTitle = { Text(stringResource(R.string.logs)) }, + topTrailing = { + ActionIconButton(Icons.Rounded.Menu, R.string.quick_actions) { + showLogsSheet = true + } + }, + ) + ) + } LaunchedEffect(isAutoScrolling) { if (isAutoScrolling) { - lazyColumnListState.animateScrollToItem(logs.size) + lazyColumnListState.animateScrollToItem(loggerState.messages.size) } } - LaunchedEffect(logs.size) { + LaunchedEffect(loggerState.messages.size) { if (isAutoScrolling) { - lazyColumnListState.animateScrollToItem(logs.size) + lazyColumnListState.animateScrollToItem(loggerState.messages.size) } } @@ -58,14 +81,13 @@ fun LogsScreen(appViewState: AppViewState, viewModel: AppViewModel) { } } - when (appViewState.bottomSheet) { - AppViewState.BottomSheet.LOGS -> { - LogsBottomSheet(viewModel) + if (showLogsSheet) { + LogsBottomSheet({ viewModel.exportLogs() }, { viewModel.deleteLogs() }) { + showLogsSheet = false } - else -> Unit } - if (logs.isEmpty()) { + if (loggerState.messages.isEmpty()) { return Box( modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), contentAlignment = Alignment.Center, @@ -80,7 +102,7 @@ fun LogsScreen(appViewState: AppViewState, viewModel: AppViewModel) { } LogList( - logs = logs, + logs = loggerState.messages, lazyColumnListState = lazyColumnListState, modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/components/LogsBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/components/LogsBottomSheet.kt index a25a30c1..0b9af37e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/components/LogsBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/logs/components/LogsBottomSheet.kt @@ -9,33 +9,24 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LogsBottomSheet(viewModel: AppViewModel) { +fun LogsBottomSheet(onExport: () -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit) { CustomBottomSheet( listOf( SheetOption( Icons.Outlined.FolderZip, stringResource(R.string.export_logs), - onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) - viewModel.handleEvent(AppEvent.ExportLogs) - }, + onClick = onExport, ), SheetOption( Icons.Outlined.Delete, stringResource(R.string.delete_logs), - onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) - viewModel.handleEvent(AppEvent.DeleteLogs) - }, + onClick = onDelete, ), ) ) { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt index e412e766..fe639a2e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt @@ -13,32 +13,36 @@ import androidx.compose.material.icons.outlined.TimerOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.components.detailedPingStatsItem import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.components.enablePingMonitoringItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel @Composable -fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { - val pingInterval: Int by - remember(uiState.appSettings) { - mutableIntStateOf(uiState.appSettings.tunnelPingIntervalSeconds) - } - val pingAttempts: Int by - remember(uiState.appSettings) { mutableIntStateOf(uiState.appSettings.tunnelPingAttempts) } - val pingTimeout: Int? by - remember(uiState.appSettings) { - mutableStateOf(uiState.appSettings.tunnelPingTimeoutSeconds) - } +fun TunnelMonitoringScreen(viewModel: SettingsViewModel) { + val sharedViewModel = LocalSharedVm.current + val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(stringResource(R.string.tunnel_monitoring)) }, + showBottomItems = true, + ) + ) + } Column( horizontalAlignment = Alignment.Start, @@ -49,8 +53,14 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { .padding(vertical = 24.dp) .padding(horizontal = 12.dp), ) { - SurfaceSelectionGroupButton(listOf(enablePingMonitoringItem(uiState, viewModel))) - if (uiState.appSettings.isPingEnabled) { + SurfaceSelectionGroupButton( + listOf( + enablePingMonitoringItem(settingsState.settings.isPingEnabled) { + viewModel.setPingEnabled(it) + } + ) + ) + if (settingsState.settings.isPingEnabled) { LabelledDropdown( title = { Text( @@ -62,9 +72,9 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { ) }, leading = { Icon(Icons.Outlined.Timer, contentDescription = null) }, - currentValue = pingInterval, + currentValue = settingsState.settings.tunnelPingIntervalSeconds, onSelected = { selected -> - viewModel.handleEvent(AppEvent.SetPingInterval(selected!!)) + selected?.let { viewModel.setTunnelPingIntervalSeconds(it) } }, options = (10..60).step(10).toList(), optionToString = { it?.toString() ?: stringResource(R.string._default) }, @@ -80,10 +90,8 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { ) }, leading = { Icon(Icons.Outlined.Replay, contentDescription = null) }, - currentValue = pingAttempts, - onSelected = { selected -> - viewModel.handleEvent(AppEvent.SetPingAttempts(selected!!)) - }, + currentValue = settingsState.settings.tunnelPingAttempts, + onSelected = { selected -> selected?.let { viewModel.setTunnelPingAttempts(it) } }, options = (1..5).toList(), optionToString = { it?.toString() ?: stringResource(R.string._default) }, ) @@ -98,7 +106,7 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { ) }, leading = { Icon(Icons.Outlined.TimerOff, contentDescription = null) }, - currentValue = pingTimeout, + currentValue = settingsState.settings.tunnelPingTimeoutSeconds, description = { Text( text = stringResource(R.string.timeout_all_attempts), @@ -109,12 +117,18 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) { ) }, onSelected = { selected -> - viewModel.handleEvent(AppEvent.SetPingTimeout(selected)) + selected?.let { viewModel.setTunnelPingTimeoutSeconds(it) } }, options = (10..20).toList() + null, optionToString = { it?.toString() ?: stringResource(R.string._default) }, ) - SurfaceSelectionGroupButton(listOf(detailedPingStatsItem(uiState, viewModel))) + SurfaceSelectionGroupButton( + listOf( + detailedPingStatsItem(settingsState.showDetailedPingStats) { + viewModel.setDetailedPingStats(it) + } + ) + ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt index 9d8ab454..a4b1ea2c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt @@ -10,12 +10,12 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun detailedPingStatsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun detailedPingStatsItem( + detailedPingStatsEnabled: Boolean, + onChange: (checked: Boolean) -> Unit, +): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.QueryStats, contentDescription = null) }, title = { @@ -25,12 +25,7 @@ fun detailedPingStatsItem(uiState: AppUiState, viewModel: AppViewModel): Selecti MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - trailing = { - ScaledSwitch( - checked = uiState.appState.showDetailedPingStats, - onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) }, + trailing = { ScaledSwitch(checked = detailedPingStatsEnabled, onClick = onChange) }, + onClick = { onChange(!detailedPingStatsEnabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt index 61615637..6463354f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt @@ -10,20 +10,15 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun enablePingMonitoringItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun enablePingMonitoringItem( + isPingEnabled: Boolean, + onChange: (checked: Boolean) -> Unit, +): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.NetworkPing, contentDescription = null) }, - trailing = { - ScaledSwitch( - checked = uiState.appSettings.isPingEnabled, - onClick = { viewModel.handleEvent(AppEvent.TogglePingMonitoring) }, - ) - }, + trailing = { ScaledSwitch(checked = isPingEnabled, onClick = onChange) }, title = { Text( text = stringResource(R.string.monitoring_ping), @@ -31,6 +26,6 @@ fun enablePingMonitoringItem(uiState: AppUiState, viewModel: AppViewModel): Sele MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - onClick = { viewModel.handleEvent(AppEvent.TogglePingMonitoring) }, + onClick = { onChange(!isPingEnabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt index 3f30194d..99cc35c0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/ProxySettingsScreen.kt @@ -1,5 +1,3 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy - import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -7,10 +5,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Forward5 import androidx.compose.material.icons.outlined.Http import androidx.compose.material.icons.outlined.RemoveRedEye +import androidx.compose.material.icons.rounded.Save import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -19,9 +18,13 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.AppProxySettings +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SecureScreenFromRecording +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel @@ -29,96 +32,52 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLab import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.isValidProxyBindAddress -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.ProxySettingsViewModel @Composable -fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { +fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) { + + val sharedViewModel = LocalSharedVm.current + + val proxySettingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + val proxySettings by remember { derivedStateOf { proxySettingsState.proxySettings } } + + var socksBindAddress by remember { mutableStateOf(proxySettings.socks5ProxyBindAddress ?: "") } + var httpBindAddress by remember { mutableStateOf(proxySettings.httpProxyBindAddress ?: "") } + var proxyUsername by remember { mutableStateOf(proxySettings.proxyUsername ?: "") } + var proxyPassword by remember { mutableStateOf(proxySettings.proxyPassword ?: "") } + var passwordVisible by remember { mutableStateOf(proxySettingsState.passwordVisible) } val keyboardController = LocalSoftwareKeyboardController.current - var socks5ProxyEnabled by rememberSaveable { - mutableStateOf(uiState.proxySettings.socks5ProxyEnabled) - } - var httpProxyEnabled by rememberSaveable { - mutableStateOf(uiState.proxySettings.httpProxyEnabled) - } - - val showAuthSettings by - remember(httpProxyEnabled, socks5ProxyEnabled) { - derivedStateOf { httpProxyEnabled || socks5ProxyEnabled } - } - - var socks5bindAddress by - rememberSaveable(uiState.proxySettings.socks5ProxyBindAddress) { - mutableStateOf(uiState.proxySettings.socks5ProxyBindAddress) - } - var httpBindAddress by - rememberSaveable(uiState.proxySettings.httpProxyBindAddress) { - mutableStateOf(uiState.proxySettings.httpProxyBindAddress) - } - - val isSocks5BindAddressError by - remember(socks5bindAddress) { - derivedStateOf { - socks5bindAddress.let { it?.isNotBlank() == true && !it.isValidProxyBindAddress() } - } - } - - val isHttpBindAddressError by - remember(httpBindAddress) { - derivedStateOf { - httpBindAddress.let { it?.isNotBlank() == true && !it.isValidProxyBindAddress() } - } - } - - var username by rememberSaveable { mutableStateOf(uiState.proxySettings.proxyUsername ?: "") } - var password by rememberSaveable { mutableStateOf(uiState.proxySettings.proxyPassword ?: "") } - val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - var passwordVisibility by rememberSaveable { mutableStateOf(false) } - - var usernameError by rememberSaveable { mutableStateOf(false) } - var passwordError by rememberSaveable { mutableStateOf(false) } - - LaunchedEffect(username) { if (username.isNotBlank() && usernameError) usernameError = false } - - LaunchedEffect(password) { if (password.isNotBlank() && passwordError) passwordError = false } + if (!proxySettingsState.stateInitialized) return LaunchedEffect(Unit) { - viewModel.handleEvent( - AppEvent.SetScreenAction { - keyboardController?.hide() - if (username.isBlank() && password.isNotBlank()) { - usernameError = true - return@SetScreenAction - } - if (password.isBlank() && username.isNotBlank()) { - passwordError = true - return@SetScreenAction - } - if (isSocks5BindAddressError || isHttpBindAddressError) return@SetScreenAction - keyboardController?.hide() - viewModel.handleEvent( - AppEvent.SetProxySettings( - socks5ProxyEnabled, - httpProxyEnabled, - httpBindAddress, - socks5bindAddress, - username, - password, - ) - ) - viewModel.handleEvent( - AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)) - ) - viewModel.handleEvent(AppEvent.PopBackStack(true)) - } + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.proxy_settings)) }, + topTrailing = { + ActionIconButton(Icons.Rounded.Save, R.string.save) { + keyboardController?.hide() + viewModel.save( + AppProxySettings( + socks5ProxyEnabled = proxySettings.socks5ProxyEnabled, + socks5ProxyBindAddress = socksBindAddress, + httpProxyEnabled = proxySettings.httpProxyEnabled, + httpProxyBindAddress = httpBindAddress, + proxyUsername = proxyUsername, + proxyPassword = proxyPassword, + ) + ) + } + }, + ) ) } @@ -141,15 +100,15 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { }, trailing = { ScaledSwitch( - checked = socks5ProxyEnabled, - onClick = { socks5ProxyEnabled = !socks5ProxyEnabled }, + checked = proxySettings.socks5ProxyEnabled, + onClick = { viewModel.setEnableSocks5(it) }, ) }, - onClick = { socks5ProxyEnabled = !socks5ProxyEnabled }, + onClick = { viewModel.setEnableSocks5(!proxySettings.socks5ProxyEnabled) }, ) ) ) - if (socks5ProxyEnabled) { + if (proxySettings.socks5ProxyEnabled) { ConfigurationTextBox( hint = stringResource( @@ -157,9 +116,9 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { AppProxySettings.DEFAULT_SOCKS_BIND_ADDRESS, ), label = stringResource(R.string.socks_5_bind_address), - value = socks5bindAddress ?: "", - isError = isSocks5BindAddressError, - onValueChange = { socks5bindAddress = it }, + value = socksBindAddress, + isError = proxySettingsState.isSocks5BindAddressError, + onValueChange = { socksBindAddress = it }, ) } SurfaceSelectionGroupButton( @@ -174,15 +133,15 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { }, trailing = { ScaledSwitch( - checked = httpProxyEnabled, - onClick = { httpProxyEnabled = !httpProxyEnabled }, + checked = proxySettings.httpProxyEnabled, + onClick = { viewModel.setEnableHttp(it) }, ) }, - onClick = { httpProxyEnabled = !httpProxyEnabled }, + onClick = { viewModel.setEnableHttp(!proxySettings.httpProxyEnabled) }, ) ) ) - if (httpProxyEnabled) { + if (proxySettings.httpProxyEnabled) { ConfigurationTextBox( hint = stringResource( @@ -190,12 +149,12 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { AppProxySettings.DEFAULT_HTTP_BIND_ADDRESS, ), label = stringResource(R.string.http_bind_address), - value = httpBindAddress ?: "", - isError = isHttpBindAddressError, + value = httpBindAddress, + isError = proxySettingsState.isHttpBindAddressError, onValueChange = { httpBindAddress = it }, ) } - if (showAuthSettings) { + if (proxySettings.httpProxyEnabled || proxySettings.socks5ProxyEnabled) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), @@ -208,26 +167,26 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { ) ) ConfigurationTextBox( - value = username, - onValueChange = { username = it }, + value = proxyUsername, + onValueChange = { proxyUsername = it }, label = stringResource(R.string.username), - isError = usernameError, + isError = proxySettingsState.isUserNameError, hint = "", keyboardActions = keyboardActions, keyboardOptions = keyboardOptions, modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( - value = password, - onValueChange = { password = it }, + value = proxyPassword, + onValueChange = { proxyPassword = it }, label = stringResource(R.string.password), - isError = passwordError, + isError = proxySettingsState.isPasswordError, hint = "", keyboardActions = keyboardActions, keyboardOptions = keyboardOptions, modifier = Modifier.fillMaxWidth(), trailing = { - IconButton(onClick = { passwordVisibility = !passwordVisibility }) { + IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( Icons.Outlined.RemoveRedEye, stringResource(R.string.show_password), @@ -235,7 +194,7 @@ fun ProxySettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { } }, visualTransformation = - if (!passwordVisibility) PasswordVisualTransformation() + if (!passwordVisible) PasswordVisualTransformation() else VisualTransformation.None, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/BackendModeBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt similarity index 56% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/BackendModeBottomSheet.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt index 90232464..8e4dd6d6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/BackendModeBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/proxy/compoents/AppModeBottomSheet.kt @@ -3,17 +3,17 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.proxy.compoents import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState import com.zaneschepke.wireguardautotunnel.util.extensions.asIcon import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun BackendModeBottomSheet(appSettings: AppSettings, viewModel: AppViewModel) { +fun AppModeBottomSheet( + onAppModeChange: (AppMode) -> Unit, + appMode: AppMode, + onDismiss: () -> Unit, +) { val context = LocalContext.current CustomBottomSheet( enumValues().map { @@ -22,13 +22,13 @@ fun BackendModeBottomSheet(appSettings: AppSettings, viewModel: AppViewModel) { icon, label = it.asTitleString(context), onClick = { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) - viewModel.handleEvent(AppEvent.SetAppMode(it)) + onDismiss() + onAppModeChange(it) }, - selected = appSettings.appMode == it, + selected = appMode == it, ) } ) { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/SystemFeaturesScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/SystemFeaturesScreen.kt index 16fde98a..8aab2252 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/SystemFeaturesScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/SystemFeaturesScreen.kt @@ -6,23 +6,41 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider import com.zaneschepke.wireguardautotunnel.ui.common.SecureScreenFromRecording import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.screens.settings.system.components.* -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel @Composable -fun SystemFeaturesScreen(appUiState: AppUiState, viewModel: AppViewModel) { +fun SystemFeaturesScreen(viewModel: SettingsViewModel) { + val sharedViewModel = LocalSharedVm.current + val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() val isTv = LocalIsAndroidTV.current + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(stringResource(R.string.android_integrations)) }, + showBottomItems = true, + ) + ) + } + SecureScreenFromRecording() Column( @@ -38,15 +56,32 @@ fun SystemFeaturesScreen(appUiState: AppUiState, viewModel: AppViewModel) { SectionDivider() SurfaceSelectionGroupButton( buildList { - add(restartAtBootItem(appUiState, viewModel)) - if (!isTv) add(alwaysOnVpnItem(appUiState, viewModel)) + add( + restartAtBootItem(settingsState.settings.isRestoreOnBootEnabled) { + viewModel.setRestoreOnBootEnabled(it) + } + ) + if (!isTv) + add( + alwaysOnVpnItem(settingsState.settings.isAlwaysOnVpnEnabled) { + viewModel.setAlwaysOnVpnEnabled(it) + } + ) } ) SectionDivider() SurfaceSelectionGroupButton( buildList { - add(appShortcutsItem(appUiState, viewModel)) - add(remoteControlItem(appUiState, viewModel)) + add( + appShortcutsItem(settingsState.settings.isShortcutsEnabled) { + viewModel.setShortcutsEnabled(it) + } + ) + add( + remoteControlItem(settingsState.isRemoteEnabled, settingsState.remoteKey) { + viewModel.setRemoteEnabled(it) + } + ) } ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/alwaysOnVpnItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/alwaysOnVpnItem.kt index d93e44e4..76b4d1ae 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/alwaysOnVpnItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/alwaysOnVpnItem.kt @@ -10,23 +10,20 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun alwaysOnVpnItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun alwaysOnVpnItem(checked: Boolean, onChange: (checked: Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.VpnLock, contentDescription = null) }, trailing = { ScaledSwitch( - enabled = - !((uiState.appSettings.isTunnelOnWifiEnabled || - uiState.appSettings.isTunnelOnEthernetEnabled || - uiState.appSettings.isTunnelOnMobileDataEnabled) && - uiState.appSettings.isAutoTunnelEnabled), - checked = uiState.appSettings.isAlwaysOnVpnEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) }, + // enabled = + // !((uiState.generalSettings.isTunnelOnWifiEnabled || + // uiState.generalSettings.isTunnelOnEthernetEnabled || + // uiState.generalSettings.isTunnelOnMobileDataEnabled) && + // uiState.generalSettings.isAutoTunnelEnabled), + checked = checked, + onClick = onChange, ) }, title = { @@ -36,6 +33,6 @@ fun alwaysOnVpnItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) }, + onClick = { onChange(!checked) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/appShortcutItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/appShortcutItem.kt index 300a6b50..c4d8b1be 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/appShortcutItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/appShortcutItem.kt @@ -10,20 +10,12 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun appShortcutsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun appShortcutsItem(checked: Boolean, onChange: (checked: Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Filled.AppShortcut, contentDescription = null) }, - trailing = { - ScaledSwitch( - checked = uiState.appSettings.isShortcutsEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) }, - ) - }, + trailing = { ScaledSwitch(checked = checked, onClick = onChange) }, title = { Text( text = stringResource(R.string.enabled_app_shortcuts), @@ -31,6 +23,6 @@ fun appShortcutsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionIte MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) }, + onClick = { onChange(!checked) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/remoteControlItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/remoteControlItem.kt index b1d42697..7175d496 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/remoteControlItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/remoteControlItem.kt @@ -15,25 +15,21 @@ import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun remoteControlItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun remoteControlItem( + checked: Boolean, + key: String?, + onChange: (checked: Boolean) -> Unit, +): SelectionItem { val clipboardManager = rememberClipboardHelper() return SelectionItem( leading = { Icon(Icons.Filled.SmartToy, contentDescription = null) }, - trailing = { - ScaledSwitch( - checked = uiState.appState.isRemoteControlEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleRemoteControl) }, - ) - }, + trailing = { ScaledSwitch(checked = checked, onClick = onChange) }, description = { - uiState.appState.remoteKey?.let { key -> - AnimatedVisibility(visible = uiState.appState.isRemoteControlEnabled) { + key?.let { key -> + AnimatedVisibility(visible = checked) { Text( text = stringResource(R.string.remote_key_template, key), style = @@ -54,6 +50,6 @@ fun remoteControlItem(uiState: AppUiState, viewModel: AppViewModel): SelectionIt MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - onClick = { viewModel.handleEvent(AppEvent.ToggleRemoteControl) }, + onClick = { onChange(!checked) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/restartAtBootItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/restartAtBootItem.kt index a9703a3b..28a5a2e1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/restartAtBootItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/system/components/restartAtBootItem.kt @@ -10,20 +10,12 @@ import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun restartAtBootItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun restartAtBootItem(enabled: Boolean, onChange: (checked: Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Restore, contentDescription = null) }, - trailing = { - ScaledSwitch( - checked = uiState.appSettings.isRestoreOnBootEnabled, - onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) }, - ) - }, + trailing = { ScaledSwitch(checked = enabled, onClick = onChange) }, title = { Text( text = stringResource(R.string.restart_at_boot), @@ -31,6 +23,6 @@ fun restartAtBootItem(uiState: AppUiState, viewModel: AppViewModel): SelectionIt MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) }, + onClick = { onChange(!enabled) }, ) } 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 59be97d3..4187f04b 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 @@ -7,6 +7,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap @@ -15,54 +16,52 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.ContactSupportOptions import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.GeneralSupportOptions import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.UpdateSection +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.canInstallPackages import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.requestInstallPackagesPermission -import com.zaneschepke.wireguardautotunnel.util.extensions.showToast -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.SupportViewModel @Composable -fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), appViewModel: AppViewModel) { +fun SupportScreen(viewModel: SupportViewModel) { val context = LocalContext.current + val sharedViewModel = LocalSharedVm.current + val navController = LocalNavController.current + val supportState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var showPermissionDialog by remember { mutableStateOf(false) } + var showPermissionDialog by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(uiState.error) { - uiState.error?.let { - viewModel.handleErrorShown() - appViewModel.handleEvent(AppEvent.ShowMessage(it)) - viewModel.handleErrorShown() - } + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(stringResource(R.string.support)) }, + showBottomItems = true, + ) + ) } - LaunchedEffect(uiState.isUptoDate) { - if (uiState.isUptoDate == true) - return@LaunchedEffect context.showToast(R.string.latest_installed) - } - - if (uiState.appUpdate != null) { + if (supportState.appUpdate != null) { InfoDialog( - onDismiss = { viewModel.handleUpdateShown() }, + onDismiss = { viewModel.dismissUpdate() }, onAttest = { if (BuildConfig.FLAVOR != Constants.STANDALONE_FLAVOR) { - uiState.appUpdate?.apkUrl?.let { context.openWebUrl(it) } + supportState.appUpdate?.apkUrl?.let { context.openWebUrl(it) } return@InfoDialog } if (context.canInstallPackages()) { - viewModel.handleDownloadAndInstallApk() + viewModel.downloadAndInstall() } else { showPermissionDialog = true } @@ -75,25 +74,13 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), appViewModel: A modifier = Modifier.fillMaxWidth(), ) { val annotatedString = buildAnnotatedString { - append("${uiState.appUpdate?.version ?: ""}\n") + append("${supportState.appUpdate?.version ?: ""}\n") // Add clickable text for second line withLink( link = LinkAnnotation.Clickable( tag = stringResource(id = R.string.release_notes), - linkInteractionListener = { - val version = - if (BuildConfig.VERSION_NAME.contains("nightly")) { - "nightly" - } else { - uiState.appUpdate - ?.version - ?.removePrefix("v") - ?.trim() ?: "" - } - val url = "${Constants.BASE_RELEASE_URL}$version".trim() - context.openWebUrl(url) - }, + linkInteractionListener = { viewModel.viewReleaseNotes() }, styles = TextLinkStyles( style = @@ -109,9 +96,9 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), appViewModel: A } Text(text = annotatedString) - if (uiState.isLoading) { + if (supportState.isLoading) { LinearProgressIndicator( - progress = { uiState.downloadProgress }, + progress = { supportState.downloadProgress }, modifier = Modifier.fillMaxWidth().padding(top = 16.dp), trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), color = MaterialTheme.colorScheme.primary, @@ -156,20 +143,9 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), appViewModel: A stringResource(R.string.thank_you), modifier = Modifier.padding(horizontal = 12.dp).padding(bottom = 12.dp), ) - UpdateSection( - onUpdateCheck = { - if ( - BuildConfig.DEBUG || - BuildConfig.VERSION_NAME.contains("beta") || - BuildConfig.FLAVOR == Constants.GOOGLE_PLAY_FLAVOR - ) - return@UpdateSection context.showToast(R.string.update_check_unsupported) - context.showToast(R.string.checking_for_update) - viewModel.handleUpdateCheck() - } - ) + UpdateSection(onUpdateCheck = { viewModel.checkForUpdate() }) SectionDivider() - GeneralSupportOptions(context) + GeneralSupportOptions(navController) SectionDivider() ContactSupportOptions(context) } 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 deleted file mode 100644 index 65163908..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.support - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.zaneschepke.wireguardautotunnel.BuildConfig -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.di.MainDispatcher -import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate -import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository -import com.zaneschepke.wireguardautotunnel.util.FileUtils -import com.zaneschepke.wireguardautotunnel.util.StringValue -import dagger.hilt.android.lifecycle.HiltViewModel -import jakarta.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@HiltViewModel -class SupportViewModel -@Inject -constructor( - private val updateRepository: UpdateRepository, - private val fileUtils: FileUtils, - @MainDispatcher private val mainDispatcher: CoroutineDispatcher, -) : ViewModel() { - - private val _uiState = MutableStateFlow(SupportUiState()) - val uiState = _uiState.asStateFlow() - - fun handleUpdateCheck() = - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - updateRepository - .checkForUpdate(BuildConfig.VERSION_NAME) - .onSuccess { appUpdate -> - _uiState.update { - it.copy( - appUpdate = appUpdate.sanitized(), - error = null, - isUptoDate = appUpdate == null, - ) - } - } - .onFailure { - _uiState.update { - it.copy(error = StringValue.StringResource(R.string.update_check_failed)) - } - } - _uiState.update { it.copy(isLoading = false) } - } - - private fun AppUpdate?.sanitized(): AppUpdate? { - return this?.copy(releaseNotes = releaseNotes.substringBefore(CHANGELOG_START)) - } - - fun handleErrorShown() = _uiState.update { it.copy(error = null) } - - fun handleUpdateShown() = _uiState.update { it.copy(appUpdate = null) } - - fun handleDownloadAndInstallApk() = - viewModelScope.launch { - with(uiState.value) { - if (appUpdate == null) return@launch - if (appUpdate.apkUrl == null || appUpdate.apkFileName == null) return@launch - _uiState.update { it.copy(isLoading = true) } - updateRepository - .downloadApk(appUpdate.apkUrl, appUpdate.apkFileName) { progress -> - withContext(mainDispatcher) { - _uiState.update { it.copy(downloadProgress = progress) } - } - } - .onSuccess { apk -> - _uiState.update { it.copy(isLoading = false) } - fileUtils.installApk(apk) - } - .onFailure { - _uiState.update { - it.copy( - isLoading = false, - error = StringValue.StringResource(R.string.update_download_failed), - ) - } - } - } - } - - companion object { - private const val CHANGELOG_START = - "SHA-256 fingerprint for the 4096-bit signing certificate:" - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/GeneralSupportOptions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/GeneralSupportOptions.kt index 6d35d713..acca4fac 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/GeneralSupportOptions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/GeneralSupportOptions.kt @@ -6,20 +6,21 @@ import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.Policy import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl @Composable -fun GeneralSupportOptions(context: android.content.Context) { - val navController = LocalNavController.current +fun GeneralSupportOptions(navController: NavController) { + val context = LocalContext.current SurfaceSelectionGroupButton( items = buildList { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt index 53794720..71d112b2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/license/LicenseScreen.kt @@ -5,11 +5,16 @@ import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.components.LicenseList +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -17,8 +22,18 @@ import kotlinx.serialization.json.Json @Composable fun LicenseScreen() { val context = LocalContext.current + val sharedViewModel = LocalSharedVm.current var licenses by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.licenses)) }, + ) + ) + } + LaunchedEffect(Unit) { licenses = loadLicenseeJson(context) } if (licenses.isEmpty()) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt new file mode 100644 index 00000000..a79b4501 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt @@ -0,0 +1,205 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels + +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton +import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog +import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberClipboardHelper +import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.ExportTunnelsBottomSheet +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.TunnelImportSheet +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.TunnelList +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.UrlImportDialog +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.util.FileUtils +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel + +@Composable +fun TunnelsScreen(viewModel: TunnelsViewModel) { + val sharedViewModel = LocalSharedVm.current + val navController = LocalNavController.current + val clipboard = rememberClipboardHelper() + + val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + var showExportSheet by rememberSaveable { mutableStateOf(false) } + var showImportSheet by rememberSaveable { mutableStateOf(false) } + var showDeleteModal by rememberSaveable { mutableStateOf(false) } + var showUrlDialog by rememberSaveable { mutableStateOf(false) } + + if (!tunnelsState.stateInitialized) return + + @Composable + fun TunnelActionBar() { + val selectedCount by + remember(tunnelsState.selectedTunnels) { + derivedStateOf { tunnelsState.selectedTunnels.size } + } + val disableDelete by + remember(tunnelsState.activeTunnels, tunnelsState.selectedTunnels) { + derivedStateOf { + tunnelsState.activeTunnels.any { active -> + tunnelsState.selectedTunnels.any { it.id == active.key.id } + } + } + } + + Row { + if (selectedCount == 0) { + val showSort by + remember(tunnelsState.tunnels) { + derivedStateOf { tunnelsState.tunnels.size > 1 } + } + if (showSort) + ActionIconButton(Icons.AutoMirrored.Rounded.Sort, R.string.sort) { + navController.navigate(Route.Sort) + } + ActionIconButton(Icons.Rounded.Add, R.string.add_tunnel) { showImportSheet = true } + return@Row + } + ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) { + viewModel.toggleSelectAllTunnels() + } + // due to permissions, and SAF issues on TV, not support less than Android 10 on + // Android TV for file exports + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ActionIconButton(Icons.Rounded.Download, R.string.download) { + showExportSheet = true + } + } + + if (selectedCount == 1) { + ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) { + viewModel.copySelectedTunnel() + } + } + + if (!disableDelete) { + ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) { + showDeleteModal = true + } + } + } + } + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(stringResource(R.string.tunnels)) }, + topTrailing = { TunnelActionBar() }, + showBottomItems = true, + ) + ) + } + + val tunnelFileImportResultLauncher = + rememberFileImportLauncherForResult( + onNoFileExplorer = { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.error_no_file_explorer) + ) + }, + onData = { data -> viewModel.importFromUri(data) }, + ) + + val scanLauncher = + rememberLauncherForActivityResult( + contract = ScanContract(), + onResult = { result -> + if (result != null && result.contents.isNotEmpty()) + viewModel.importFromQr(result.contents) + }, + ) + + val requestPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted + -> + if (!isGranted) { + sharedViewModel.showSnackMessage( + StringValue.StringResource(R.string.camera_permission_required) + ) + return@rememberLauncherForActivityResult + } + scanLauncher.launch( + ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false) + ) + } + + if (showDeleteModal) { + InfoDialog( + onDismiss = { showDeleteModal = false }, + onAttest = { + viewModel.deleteSelectedTunnels() + showDeleteModal = false + }, + title = { Text(text = stringResource(R.string.delete_tunnel)) }, + body = { Text(text = stringResource(R.string.delete_tunnel_message)) }, + confirmText = { Text(text = stringResource(R.string.yes)) }, + ) + } + + if (showExportSheet) { + ExportTunnelsBottomSheet({ type, uri -> + viewModel.exportSelectedTunnels(type, uri, tunnelsState.selectedTunnels) + }) { + showExportSheet = false + viewModel.clearSelectedTunnels() + } + } + + if (showImportSheet) { + TunnelImportSheet( + onDismiss = { showImportSheet = false }, + onFileClick = { + tunnelFileImportResultLauncher.launch(FileUtils.ALLOWED_TV_FILE_TYPES) + }, + onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) }, + onClipboardClick = { + clipboard.paste { result -> + if (result != null) viewModel.importFromClipboard(result) + } + }, + onManualImportClick = { navController.navigate(Route.Config(null)) }, + onUrlClick = { showUrlDialog = true }, + ) + } + + if (showUrlDialog) { + UrlImportDialog( + onDismiss = { showUrlDialog = false }, + onConfirm = { url -> + viewModel.importFromUrl(url) + showUrlDialog = false + }, + ) + } + + TunnelList( + tunnelsState, + modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp), + viewModel, + sharedViewModel, + navController, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt new file mode 100644 index 00000000..ec94bb38 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt @@ -0,0 +1,80 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.MobileDataTunnelItem +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.PingRestartItem +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.WifiTunnelItem +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.ethernetTunnelItem +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel + +@Composable +fun TunnelAutoTunnelScreen(tunnelId: Int, viewModel: TunnelsViewModel) { + val sharedViewModel = LocalSharedVm.current + val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + val tunnelConf by + remember(tunnelsState.tunnels) { + derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId }!! } + } + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState(showBottomItems = true, topTitle = { Text(tunnelConf.name) }) + ) + } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 24.dp) + .padding(horizontal = 12.dp), + ) { + SurfaceSelectionGroupButton( + items = + buildList { + if (tunnelsState.isPingEnabled) { + add( + PingRestartItem(tunnelConf.restartOnPingFailure) { + viewModel.setRestartOnPing(tunnelId, it) + } + ) + } + add( + MobileDataTunnelItem(tunnelConf.isMobileDataTunnel) { + viewModel.setMobileDataTunnel(tunnelId, it) + } + ) + add( + ethernetTunnelItem(tunnelConf.isEthernetTunnel) { + viewModel.setEthernetTunnel(tunnelId, it) + } + ) + add( + WifiTunnelItem( + tunnelConf.tunnelNetworks, + tunnelsState.isWildcardsEnabled, + onSaveTunnelNetwork = { viewModel.addTunnelNetwork(tunnelId, it) }, + onDeleteTunnelNetwork = { viewModel.removeTunnelNetwork(tunnelId, it) }, + ) + ) + } + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/MobileDataTunnelItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/MobileDataTunnelItem.kt similarity index 62% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/MobileDataTunnelItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/MobileDataTunnelItem.kt index 46f8a360..27de554f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/MobileDataTunnelItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/MobileDataTunnelItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.PhoneAndroid @@ -8,14 +8,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun MobileDataTunnelItem(enabled: Boolean, onClick: (Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.PhoneAndroid, contentDescription = null) }, title = { @@ -31,12 +28,7 @@ fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): Selec style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, - trailing = { - ScaledSwitch( - checked = tunnelConf.isMobileDataTunnel, - onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) }, + trailing = { ScaledSwitch(checked = enabled, onClick = onClick) }, + onClick = { onClick(!enabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/PingRestartItem.kt similarity index 56% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/PingRestartItem.kt index 9526699d..c51b96cf 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/PingRestartItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NetworkPing @@ -8,14 +8,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun PingRestartItem(enabled: Boolean, onClick: (Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.NetworkPing, contentDescription = null) }, title = { @@ -25,12 +22,7 @@ fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionI MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), ) }, - trailing = { - ScaledSwitch( - checked = tunnelConf.restartOnPingFailure, - onClick = { viewModel.handleEvent(AppEvent.ToggleRestartOnPingFailure(tunnelConf)) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleRestartOnPingFailure(tunnelConf)) }, + trailing = { ScaledSwitch(checked = enabled, onClick = onClick) }, + onClick = { onClick(!enabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/WifiTunnelItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/WifiTunnelItem.kt similarity index 66% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/WifiTunnelItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/WifiTunnelItem.kt index 1a3ca572..f3285eca 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/WifiTunnelItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/WifiTunnelItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons @@ -6,29 +6,30 @@ import androidx.compose.material.icons.outlined.Security import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.TrustedNetworkTextBox import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WildcardsLabel import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable fun WifiTunnelItem( - tunnelConf: TunnelConf, - appSettings: AppSettings, - viewModel: AppViewModel, - currentText: String, - onTextChange: (String) -> Unit, + tunnelNetworks: Set, + isWildcardsEnabled: Boolean, + onSaveTunnelNetwork: (String) -> Unit, + onDeleteTunnelNetwork: (String) -> Unit, ): SelectionItem { + + var currentText by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(tunnelNetworks) { currentText = "" } + return SelectionItem( title = { Row( @@ -53,16 +54,13 @@ fun WifiTunnelItem( }, description = { TrustedNetworkTextBox( - trustedNetworks = tunnelConf.tunnelNetworks, - onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) }, + trustedNetworks = tunnelNetworks, + onDelete = onDeleteTunnelNetwork, currentText = currentText, - onSave = { - viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf)) - onTextChange("") // Reset the text field after saving - }, - onValueChange = onTextChange, + onSave = onSaveTunnelNetwork, + onValueChange = { currentText = it }, supporting = { - if (appSettings.isWildcardsEnabled) { + if (isWildcardsEnabled) { WildcardsLabel() } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/ethernetTunnelItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/ethernetTunnelItem.kt similarity index 62% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/ethernetTunnelItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/ethernetTunnelItem.kt index 1c71c845..c2cff8b1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/ethernetTunnelItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/components/ethernetTunnelItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SettingsEthernet @@ -8,14 +8,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun ethernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun ethernetTunnelItem(enabled: Boolean, onClick: (Boolean) -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) }, title = { @@ -31,12 +28,7 @@ fun ethernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): Selecti style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, - trailing = { - ScaledSwitch( - checked = tunnelConf.isEthernetTunnel, - onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) }, + trailing = { ScaledSwitch(checked = enabled, onClick = onClick) }, + onClick = { onClick(!enabled) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ExportTunnelsBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt similarity index 74% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ExportTunnelsBottomSheet.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt index 20281f97..afd17d96 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ExportTunnelsBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ExportTunnelsBottomSheet.kt @@ -1,5 +1,6 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components +import android.net.Uri import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material3.ExperimentalMaterial3Api @@ -8,22 +9,25 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AuthorizationPromptWrapper -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ExportTunnelsBottomSheet(viewModel: AppViewModel) { +fun ExportTunnelsBottomSheet( + onExport: (configType: ConfigType, uri: Uri?) -> Unit, + onDismiss: () -> Unit, +) { val context = LocalContext.current val isTv = LocalIsAndroidTV.current + val sharedViewModel = LocalSharedVm.current var exportConfigType by remember { mutableStateOf(ConfigType.WG) } var showAuthPrompt by remember { mutableStateOf(false) } @@ -32,22 +36,19 @@ fun ExportTunnelsBottomSheet(viewModel: AppViewModel) { val selectedTunnelsExportLauncher = rememberFileExportLauncherForResult( - mimeType = Constants.ZIP_FILE_MIME_TYPE, + mimeType = FileUtils.ZIP_FILE_MIME_TYPE, onResult = { file -> if (file != null) { - viewModel.handleEvent(AppEvent.ExportSelectedTunnels(exportConfigType, file)) - } else { - viewModel.handleEvent(AppEvent.ClearSelectedTunnels) - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) - } + onExport(ConfigType.WG, file) + } else onDismiss() }, ) fun handleFileExport() { - if (context.hasSAFSupport(Constants.ZIP_FILE_MIME_TYPE)) { + if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) { selectedTunnelsExportLauncher.launch(Constants.DEFAULT_EXPORT_FILE_NAME) } else { - viewModel.handleEvent(AppEvent.ExportSelectedTunnels(exportConfigType, null)) + onExport(exportConfigType, null) } } @@ -66,7 +67,6 @@ fun ExportTunnelsBottomSheet(viewModel: AppViewModel) { isAuthorized = true shouldExport = true }, - viewModel = viewModel, ) } @@ -98,6 +98,6 @@ fun ExportTunnelsBottomSheet(viewModel: AppViewModel) { ), ) ) { - viewModel.handleEvent(AppEvent.SetBottomSheet(AppViewState.BottomSheet.NONE)) + onDismiss() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/GettingStartedLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt similarity index 96% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/GettingStartedLabel.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt index bb16c43f..dce091cf 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/GettingStartedLabel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/GettingStartedLabel.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ScrollDismissMultiFab.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt similarity index 93% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ScrollDismissMultiFab.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt index cd0d26a8..b19288e0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/ScrollDismissMultiFab.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/ScrollDismissMultiFab.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelImportSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt similarity index 94% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelImportSheet.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt index ed274429..39cfaf0e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelImportSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelImportSheet.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* @@ -6,9 +6,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt similarity index 55% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt index fca14f7f..68627cc0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable @@ -17,29 +17,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel @OptIn(ExperimentalFoundationApi::class) @Composable fun TunnelList( - appUiState: AppUiState, - selectedTunnels: List, + tunnelsState: TunnelsUiState, modifier: Modifier = Modifier, - onToggleTunnel: (TunnelConf, Boolean) -> Unit, - viewModel: AppViewModel, + viewModel: TunnelsViewModel, + sharedViewModel: SharedAppViewModel, + navController: NavController, ) { val isTv = LocalIsAndroidTV.current val context = LocalContext.current - val navController = LocalNavController.current val lazyListState = rememberLazyListState() @@ -48,52 +46,54 @@ fun TunnelList( verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), modifier = modifier - .pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput } + .pointerInput(Unit) { + if (tunnelsState.tunnels.isEmpty()) return@pointerInput + viewModel.clearSelectedTunnels() + } .overscroll(rememberOverscrollEffect()), state = lazyListState, userScrollEnabled = true, reverseLayout = false, flingBehavior = ScrollableDefaults.flingBehavior(), ) { - if (appUiState.tunnels.isEmpty()) { + if (tunnelsState.tunnels.isEmpty()) { item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) } } - items(appUiState.tunnels, key = { it.id }) { tunnel -> + items(tunnelsState.tunnels, key = { it.id }) { tunnel -> val tunnelState = - remember(appUiState.activeTunnels) { - appUiState.activeTunnels.getValueById(tunnel.id) ?: TunnelState() + remember(tunnelsState.activeTunnels) { + tunnelsState.activeTunnels.getValueById(tunnel.id) ?: TunnelState() + } + val selected = + remember(tunnelsState.selectedTunnels) { + tunnelsState.selectedTunnels.any { it.id == tunnel.id } } - val selected = remember(selectedTunnels) { selectedTunnels.any { it.id == tunnel.id } } TunnelRowItem( state = tunnelState, isSelected = selected, tunnel = tunnel, tunnelState = tunnelState, - appSettings = appUiState.appSettings, - onTvClick = { - navController.navigate(Route.TunnelOptions(tunnel.id)) - viewModel.handleEvent(AppEvent.ClearSelectedTunnels) + onTvClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) }, + onToggleSelectedTunnel = { tunnel -> viewModel.toggleSelectedTunnel(tunnel.id) }, + onSwitchClick = { checked -> + if (checked) sharedViewModel.startTunnel(tunnel) + else sharedViewModel.stopTunnel(tunnel) }, - onToggleSelectedTunnel = { - viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(it)) - }, - onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) }, isTv = isTv, - showDetailedStats = appUiState.appState.showDetailedPingStats, + isPingEnabled = tunnelsState.isPingEnabled, + showDetailedStats = tunnelsState.showPingStats, modifier = if (!isTv) Modifier.combinedClickable( onClick = { - if (selectedTunnels.isNotEmpty()) { - viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel)) + if (tunnelsState.selectedTunnels.isNotEmpty()) { + viewModel.toggleSelectedTunnel(tunnel.id) } else { navController.navigate(Route.TunnelOptions(tunnel.id)) - viewModel.handleEvent(AppEvent.ClearSelectedTunnels) + viewModel.clearSelectedTunnels() } }, - onLongClick = { - viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel)) - }, + onLongClick = { viewModel.toggleSelectedTunnel(tunnel.id) }, interactionSource = remember { MutableInteractionSource() }, indication = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelRowItem.kt similarity index 92% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelRowItem.kt index cac41578..f6c9ef79 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelRowItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -20,7 +20,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem @@ -33,11 +32,11 @@ fun TunnelRowItem( isSelected: Boolean, tunnel: TunnelConf, tunnelState: TunnelState, - appSettings: AppSettings, onTvClick: () -> Unit, onToggleSelectedTunnel: (TunnelConf) -> Unit, onSwitchClick: (Boolean) -> Unit, isTv: Boolean, + isPingEnabled: Boolean, showDetailedStats: Boolean, modifier: Modifier = Modifier, ) { @@ -112,12 +111,7 @@ fun TunnelRowItem( text = tunnel.tunName, expanded = { if (tunnelState.status != TunnelStatus.Down) { - TunnelStatisticsRow( - tunnelState, - tunnel, - appSettings.isPingEnabled, - showDetailedStats, - ) + TunnelStatisticsRow(tunnelState, tunnel, isPingEnabled, showDetailedStats) } }, trailing = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsRow.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt similarity index 99% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsRow.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt index b96eeaa4..0b1a80c3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelStatisticsRow.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelStatisticsRow.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/UrlImportDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt similarity index 95% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/UrlImportDialog.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt index e6094743..645fbb72 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/UrlImportDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/UrlImportDialog.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt new file mode 100644 index 00000000..3183dd46 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt @@ -0,0 +1,150 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.SecureScreenFromRecording +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AuthorizationPromptWrapper +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.AddPeerButton +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.InterfaceSection +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.PeersSection +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel + +@Composable +fun ConfigScreen(tunnelId: Int? = null, viewModel: TunnelsViewModel = hiltViewModel()) { + val sharedViewModel = LocalSharedVm.current + val isTv = LocalIsAndroidTV.current + val keyboardController = LocalSoftwareKeyboardController.current + + val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + val tunnelConf by + remember(tunnelsState.tunnels) { + derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId } } + } + + var configProxy by remember { + mutableStateOf(tunnelConf?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy()) + } + + var tunnelName by remember { mutableStateOf(tunnelConf?.name ?: "") } + + LaunchedEffect(key1 = Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(tunnelConf?.name ?: stringResource(R.string.new_tunnel)) }, + topTrailing = { + ActionIconButton(Icons.Rounded.Save, R.string.save) { + keyboardController?.hide() + viewModel.saveConfigProxy(tunnelId, configProxy, tunnelName) + } + }, + ) + ) + } + + val isTunnelNameTaken by + remember(tunnelName, tunnelsState.tunnels) { + derivedStateOf { + tunnelsState.tunnels.any { it.name == tunnelName && it.id != tunnelConf?.id } + } + } + + var showAuthPrompt by rememberSaveable { mutableStateOf(false) } + var isAuthorized by rememberSaveable { mutableStateOf(isTv) } + + SecureScreenFromRecording() + + if (showAuthPrompt) { + AuthorizationPromptWrapper( + onDismiss = { showAuthPrompt = false }, + onSuccess = { + showAuthPrompt = false + isAuthorized = true + }, + ) + } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 12.dp, bottom = 24.dp) + .padding(horizontal = 12.dp), + ) { + InterfaceSection( + configProxy = configProxy, + tunnelName, + isTunnelNameTaken, + isAuthorized, + toggleAuthPrompt = { showAuthPrompt = !showAuthPrompt }, + onInterfaceChange = { configProxy = configProxy.copy(`interface` = it) }, + onTunnelNameChange = { tunnelName = it }, + onMimicQuic = { + configProxy = configProxy.copy(`interface` = configProxy.`interface`.setQuicMimic()) + }, + onMimicDns = { + configProxy = configProxy.copy(`interface` = configProxy.`interface`.setDnsMimic()) + }, + onMimicSip = { + configProxy = configProxy.copy(`interface` = configProxy.`interface`.setSipMimic()) + }, + ) + PeersSection( + configProxy, + isAuthorized, + onRemove = { + configProxy = + configProxy.copy( + peers = configProxy.peers.toMutableList().apply { removeAt(it) } + ) + }, + onToggleLan = { index -> + configProxy = + configProxy.copy( + peers = + configProxy.peers.toMutableList().apply { + val peer = get(index) + val updated = + if (peer.isLanExcluded()) peer.includeLan() + else peer.excludeLan() + set(index, updated) + } + ) + }, + onUpdatePeer = { peer, index -> + configProxy = + configProxy.copy( + peers = configProxy.peers.toMutableList().apply { set(index, peer) } + ) + }, + showAuth = { showAuthPrompt = true }, + ) + AddPeerButton() { configProxy = configProxy.copy(peers = configProxy.peers + PeerProxy()) } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt similarity index 70% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt index 97611c0c..fb787ab4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/AddPeerButton.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -12,15 +12,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel @Composable -fun AddPeerButton(viewModel: ConfigViewModel) { +fun AddPeerButton(onClick: () -> Unit) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), ) { - TextButton(onClick = { viewModel.addPeer() }) { Text(stringResource(R.string.add_peer)) } + TextButton(onClick = onClick) { Text(stringResource(R.string.add_peer)) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt similarity index 71% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt index e7de8401..22653981 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceDropdown.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -24,10 +24,16 @@ fun InterfaceDropdown( onToggleScripts: () -> Unit, onToggleAmneziaValues: () -> Unit, onToggleAmneziaCompatibility: () -> Unit, + onMimicQuic: () -> Unit, + onMimicDns: () -> Unit, + onMimicSip: () -> Unit, ) { Column { IconButton(modifier = Modifier.size(iconSize), onClick = { onExpandedChange(true) }) { - Icon(Icons.Rounded.MoreVert, contentDescription = "More") + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.quick_actions), + ) } DropdownMenu( expanded = expanded, @@ -71,6 +77,27 @@ fun InterfaceDropdown( onExpandedChange(false) }, ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_quic)) }, + onClick = { + onMimicQuic() + onExpandedChange(false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_dns)) }, + onClick = { + onMimicDns() + onExpandedChange(false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.mimic_sip)) }, + onClick = { + onMimicSip() + onExpandedChange(false) + }, + ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt similarity index 61% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt index d766ab9c..dde58735 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceFields.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -32,7 +33,7 @@ import java.util.* fun InterfaceFields( interfaceState: InterfaceProxy, showAuthPrompt: () -> Unit, - isAuthenticated: Boolean, + isAuthorized: Boolean, showScripts: Boolean, showAmneziaValues: Boolean, onInterfaceChange: (InterfaceProxy) -> Unit, @@ -41,6 +42,7 @@ fun InterfaceFields( val clipboardManager = rememberClipboardHelper() val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + val locale = Locale.getDefault() Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { ConfigurationTextBox( @@ -50,14 +52,14 @@ fun InterfaceFields( .lowercase(Locale.getDefault()), onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) }, label = stringResource(R.string.private_key), - modifier = Modifier.fillMaxWidth().clickable { if (!isAuthenticated) showAuthPrompt() }, + modifier = Modifier.fillMaxWidth().clickable { if (!isAuthorized) showAuthPrompt() }, visualTransformation = - if (isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(), + if (isAuthorized) VisualTransformation.None else PasswordVisualTransformation(), trailing = { IconButton( enabled = true, onClick = { - if (!isAuthenticated) return@IconButton showAuthPrompt() + if (!isAuthorized) return@IconButton showAuthPrompt() val keypair = KeyPair() onInterfaceChange( interfaceState.copy( @@ -71,12 +73,12 @@ fun InterfaceFields( Icons.Rounded.Refresh, stringResource(R.string.rotate_keys), tint = - if (isAuthenticated) MaterialTheme.colorScheme.onSurface + if (isAuthorized) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, ) } }, - enabled = isAuthenticated, + enabled = isAuthorized, singleLine = true, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, @@ -108,7 +110,10 @@ fun InterfaceFields( onValueChange = { onInterfaceChange(interfaceState.copy(addresses = it)) }, label = stringResource(R.string.addresses), hint = - stringResource(R.string.hint_template, stringResource(R.string.comma_separated)) + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ) .lowercase(Locale.getDefault()), modifier = Modifier.fillMaxWidth(), ) @@ -118,6 +123,7 @@ fun InterfaceFields( label = stringResource(R.string.listen_port), hint = stringResource(R.string.random), modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), ) Row( modifier = Modifier.fillMaxWidth(), @@ -129,15 +135,16 @@ fun InterfaceFields( label = stringResource(R.string.dns_servers), hint = stringResource(R.string.hint_template, stringResource(R.string.comma_separated)) - .lowercase(Locale.getDefault()), + .lowercase(locale), modifier = Modifier.weight(3f), ) ConfigurationTextBox( value = interfaceState.mtu, onValueChange = { onInterfaceChange(interfaceState.copy(mtu = it)) }, label = stringResource(R.string.mtu), - hint = stringResource(R.string.auto).lowercase(Locale.getDefault()), + hint = stringResource(R.string.auto).lowercase(locale), modifier = Modifier.weight(2f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), ) } if (showScripts) { @@ -145,28 +152,44 @@ fun InterfaceFields( value = interfaceState.preUp, onValueChange = { onInterfaceChange(interfaceState.copy(preUp = it)) }, label = stringResource(R.string.pre_up), - hint = stringResource(R.string.hint_template, (R.string.comma_separated)), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( value = interfaceState.postUp, onValueChange = { onInterfaceChange(interfaceState.copy(postUp = it)) }, label = stringResource(R.string.post_up), - hint = stringResource(R.string.hint_template, (R.string.comma_separated)), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( value = interfaceState.preDown, onValueChange = { onInterfaceChange(interfaceState.copy(preDown = it)) }, label = stringResource(R.string.pre_down), - hint = stringResource(R.string.hint_template, (R.string.comma_separated)), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( value = interfaceState.postDown, onValueChange = { onInterfaceChange(interfaceState.copy(postDown = it)) }, label = stringResource(R.string.post_down), - hint = stringResource(R.string.hint_template, (R.string.comma_separated)), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.comma_separated).lowercase(locale), + ), modifier = Modifier.fillMaxWidth(), ) } @@ -175,28 +198,32 @@ fun InterfaceFields( value = interfaceState.junkPacketCount, onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketCount = it)) }, label = stringResource(R.string.junk_packet_count), - hint = stringResource(R.string.hint_template, (R.string.comma_separated)), + hint = stringResource(R.string.range_hint, 1, 128), modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), ) ConfigurationTextBox( value = interfaceState.junkPacketMinSize, onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMinSize = it)) }, label = stringResource(R.string.junk_packet_minimum_size), - hint = stringResource(R.string.junk_packet_minimum_size).lowercase(), + hint = stringResource(R.string.range_hint, 1, 1279), modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), ) ConfigurationTextBox( value = interfaceState.junkPacketMaxSize, onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMaxSize = it)) }, label = stringResource(R.string.junk_packet_maximum_size), - hint = stringResource(R.string.junk_packet_maximum_size).lowercase(), + hint = stringResource(R.string.range_hint, 2, 1280), modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), ) ConfigurationTextBox( value = interfaceState.initPacketJunkSize, onValueChange = { onInterfaceChange(interfaceState.copy(initPacketJunkSize = it)) }, label = stringResource(R.string.init_packet_junk_size), - hint = stringResource(R.string.init_packet_junk_size).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 0, 64), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( @@ -205,7 +232,8 @@ fun InterfaceFields( onInterfaceChange(interfaceState.copy(responsePacketJunkSize = it)) }, label = stringResource(R.string.response_packet_junk_size), - hint = stringResource(R.string.response_packet_junk_size).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 0, 64), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( @@ -214,7 +242,8 @@ fun InterfaceFields( onInterfaceChange(interfaceState.copy(initPacketMagicHeader = it)) }, label = stringResource(R.string.init_packet_magic_header), - hint = stringResource(R.string.init_packet_magic_header).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 1, 4), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( @@ -223,7 +252,8 @@ fun InterfaceFields( onInterfaceChange(interfaceState.copy(responsePacketMagicHeader = it)) }, label = stringResource(R.string.response_packet_magic_header), - hint = stringResource(R.string.response_packet_magic_header).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 1, 4), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( @@ -232,7 +262,8 @@ fun InterfaceFields( onInterfaceChange(interfaceState.copy(underloadPacketMagicHeader = it)) }, label = stringResource(R.string.underload_packet_magic_header), - hint = stringResource(R.string.underload_packet_magic_header).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 1, 4), modifier = Modifier.fillMaxWidth(), ) ConfigurationTextBox( @@ -241,7 +272,76 @@ fun InterfaceFields( onInterfaceChange(interfaceState.copy(transportPacketMagicHeader = it)) }, label = stringResource(R.string.transport_packet_magic_header), - hint = stringResource(R.string.transport_packet_magic_header).lowercase(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = stringResource(R.string.range_hint, 1, 4), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i1, + onValueChange = { onInterfaceChange(interfaceState.copy(i1 = it)) }, + label = "I1", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i2, + onValueChange = { onInterfaceChange(interfaceState.copy(i2 = it)) }, + label = "I2", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i3, + onValueChange = { onInterfaceChange(interfaceState.copy(i3 = it)) }, + label = "I3", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i4, + onValueChange = { onInterfaceChange(interfaceState.copy(i4 = it)) }, + label = "I4", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.i5, + onValueChange = { onInterfaceChange(interfaceState.copy(i5 = it)) }, + label = "I5", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.j1, + onValueChange = { onInterfaceChange(interfaceState.copy(j1 = it)) }, + label = "J1", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.j2, + onValueChange = { onInterfaceChange(interfaceState.copy(j2 = it)) }, + label = "J2", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.j3, + onValueChange = { onInterfaceChange(interfaceState.copy(j3 = it)) }, + label = "J3", + hint = stringResource(R.string.hint_template, ""), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.itime, + onValueChange = { onInterfaceChange(interfaceState.copy(itime = it)) }, + label = "ITime", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + hint = + stringResource( + R.string.hint_template, + stringResource(R.string.seconds).lowercase(locale), + ), modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt new file mode 100644 index 00000000..6ff58a98 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/InterfaceSection.kt @@ -0,0 +1,107 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import java.util.* + +@Composable +fun InterfaceSection( + configProxy: ConfigProxy, + tunnelName: String, + isTunnelNameTaken: Boolean, + isAuthorized: Boolean, + toggleAuthPrompt: () -> Unit, + onInterfaceChange: (InterfaceProxy) -> Unit, + onTunnelNameChange: (String) -> Unit, + onMimicQuic: () -> Unit, + onMimicDns: () -> Unit, + onMimicSip: () -> Unit, +) { + var showAmneziaValues by rememberSaveable { + mutableStateOf(configProxy.`interface`.isAmneziaEnabled()) + } + var showScripts by rememberSaveable { mutableStateOf(configProxy.hasScripts()) } + var isDropDownExpanded by rememberSaveable { mutableStateOf(false) } + val isAmneziaCompatibilitySet = + remember(configProxy.`interface`) { + configProxy.`interface`.isAmneziaCompatibilityModeSet() + } + + fun toggleAmneziaCompat() { + val (show, interfaceProxy) = + if (configProxy.`interface`.isAmneziaCompatibilityModeSet()) { + Pair(false, configProxy.`interface`.resetAmneziaProperties()) + } else Pair(true, configProxy.`interface`.toAmneziaCompatibilityConfig()) + showAmneziaValues = show + onInterfaceChange(interfaceProxy) + } + + Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(horizontal = 16.dp).focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + GroupLabel(stringResource(R.string.interface_)) + InterfaceDropdown( + expanded = isDropDownExpanded, + onExpandedChange = { isDropDownExpanded = it }, + showScripts = showScripts, + showAmneziaValues = showAmneziaValues, + isAmneziaCompatibilitySet = isAmneziaCompatibilitySet, + onToggleScripts = { showScripts = !showScripts }, + onToggleAmneziaValues = { showAmneziaValues = !showAmneziaValues }, + onToggleAmneziaCompatibility = { toggleAmneziaCompat() }, + onMimicQuic = { + showAmneziaValues = true + onMimicQuic() + }, + onMimicDns = { + showAmneziaValues = true + onMimicDns() + }, + onMimicSip = { + showAmneziaValues = true + onMimicSip() + }, + ) + } + ConfigurationTextBox( + value = tunnelName, + onValueChange = onTunnelNameChange, + label = stringResource(R.string.name), + isError = isTunnelNameTaken, + hint = + stringResource(R.string.hint_template, stringResource(R.string.tunnel_name)) + .lowercase(Locale.getDefault()), + modifier = Modifier.fillMaxWidth(), + ) + InterfaceFields( + interfaceState = configProxy.`interface`, + showAuthPrompt = toggleAuthPrompt, + showScripts = showScripts, + showAmneziaValues = showAmneziaValues, + onInterfaceChange = onInterfaceChange, + isAuthorized = isAuthorized, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt similarity index 97% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt index 79b076bc..883d1694 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeerFields.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt similarity index 83% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt index 1612c2eb..98c1a542 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/components/PeersSection.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup @@ -16,13 +16,20 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel -import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize @Composable -fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { - uiState.configProxy.peers.forEachIndexed { index, peer -> +fun PeersSection( + configProxy: ConfigProxy, + isAuthorized: Boolean, + onRemove: (index: Int) -> Unit, + onToggleLan: (index: Int) -> Unit, + onUpdatePeer: (PeerProxy, index: Int) -> Unit, + showAuth: () -> Unit, +) { + configProxy.peers.forEachIndexed { index, peer -> var isDropDownExpanded by remember { mutableStateOf(false) } Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surface) { @@ -42,7 +49,7 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { ) { IconButton( modifier = Modifier.size(iconSize), - onClick = { viewModel.removePeer(index) }, + onClick = { onRemove(index) }, ) { Icon( Icons.Rounded.Delete, @@ -75,7 +82,7 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { ) }, onClick = { - viewModel.toggleLanExclusion(index) + onToggleLan(index) isDropDownExpanded = false }, ) @@ -85,9 +92,9 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { } PeerFields( peer = peer, - onPeerChange = { viewModel.updatePeer(index, it) }, - showAuthPrompt = { viewModel.toggleShowAuthPrompt() }, - isAuthenticated = uiState.isAuthenticated, + onPeerChange = { onUpdatePeer(it, index) }, + showAuthPrompt = showAuth, + isAuthenticated = isAuthorized, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt similarity index 63% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt index 67b909d6..3f710997 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/sort/SortScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.sort +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.sort import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.layout.* @@ -11,9 +11,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.SortByAlpha import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -21,58 +25,56 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState import com.zaneschepke.wireguardautotunnel.util.extensions.isSortedBy -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent -import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel import sh.calvin.reorderable.DragGestureDetector import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @Composable -fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { - +fun SortScreen(viewModel: TunnelsViewModel) { + val sharedViewModel = LocalSharedVm.current + val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() val hapticFeedback = LocalHapticFeedback.current val isTv = LocalIsAndroidTV.current - var sortAscending by remember { mutableStateOf(null) } - var sortedTunnels by remember { mutableStateOf(appUiState.tunnels.sortedBy { it.position }) } + var sortAscending by rememberSaveable { mutableStateOf(null) } + var editableTunnels by rememberSaveable { mutableStateOf(tunnelsState.tunnels) } LaunchedEffect(Unit) { - viewModel.uiEvent.collect { uiEvent -> - when (uiEvent) { - UiEvent.SortTunnels -> { - sortAscending = - when (sortAscending) { - null -> !sortedTunnels.isSortedBy { it.name } - true -> false - false -> null + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(stringResource(R.string.sort)) }, + topTrailing = { + Row { + ActionIconButton(Icons.Rounded.SortByAlpha, R.string.sort) { + sortAscending = + when (sortAscending) { + null -> !editableTunnels.isSortedBy { it.name } + true -> false + false -> null + } + editableTunnels = + when (sortAscending) { + true -> editableTunnels.sortedBy { it.name } + false -> editableTunnels.sortedByDescending { it.name } + null -> tunnelsState.tunnels + } } - sortedTunnels = - when (sortAscending) { - true -> sortedTunnels.sortedBy { it.name } - false -> sortedTunnels.sortedByDescending { it.name } - null -> sortedTunnels.sortedBy { it.position } + ActionIconButton(Icons.Rounded.Save, R.string.save) { + viewModel.saveSortChanges(editableTunnels) } - } - } - } - } - - LaunchedEffect(Unit) { - viewModel.handleEvent( - AppEvent.SetScreenAction { - viewModel.handleEvent( - AppEvent.SaveAllConfigs( - sortedTunnels.mapIndexed { index, conf -> conf.copy(position = index) } - ) - ) - viewModel.handleEvent(AppEvent.PopBackStack(true)) - } + } + }, + ) ) } @@ -83,8 +85,8 @@ fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { lazyListState, scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(), ) { from, to -> - sortedTunnels = - sortedTunnels.toMutableList().apply { add(to.index, removeAt(from.index)) } + editableTunnels = + editableTunnels.toMutableList().apply { add(to.index, removeAt(from.index)) } hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } @@ -92,7 +94,7 @@ fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), modifier = - Modifier.pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput } + Modifier.pointerInput(Unit) { if (tunnelsState.tunnels.isEmpty()) return@pointerInput } .overscroll(rememberOverscrollEffect()) .padding(horizontal = 16.dp, vertical = 24.dp), state = lazyListState, @@ -100,7 +102,7 @@ fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { reverseLayout = false, flingBehavior = ScrollableDefaults.flingBehavior(), ) { - itemsIndexed(sortedTunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel -> + itemsIndexed(editableTunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel -> ReorderableItem(reorderableLazyListState, tunnel.id) { isDragging -> ExpandingRowListItem( leading = {}, @@ -117,8 +119,8 @@ fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { Row { IconButton( onClick = { - sortedTunnels = - sortedTunnels.toMutableList().apply { + editableTunnels = + editableTunnels.toMutableList().apply { add(index - 1, removeAt(index)) } }, @@ -133,12 +135,12 @@ fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) { } IconButton( onClick = { - sortedTunnels = - sortedTunnels.toMutableList().apply { + editableTunnels = + editableTunnels.toMutableList().apply { add(index + 1, removeAt(index)) } }, - enabled = index != sortedTunnels.count() - 1, + enabled = index != editableTunnels.size - 1, ) { Icon( Icons.Default.ArrowDownward, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt new file mode 100644 index 00000000..2bff4d1d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt @@ -0,0 +1,80 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SplitTunnelContent +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel + +@Composable +fun SplitTunnelScreen(tunnelId: Int, viewModel: SplitTunnelViewModel = hiltViewModel()) { + val splitTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val sharedViewModel = LocalSharedVm.current + + if (!splitTunnelState.stateInitialized) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(30.dp), strokeWidth = 5.dp) + } + return + } + val tunnelConf by + remember(splitTunnelState.tunnels) { + derivedStateOf { splitTunnelState.tunnels.find { it.id == tunnelId }!! } + } + + val conf by remember { derivedStateOf { tunnelConf.toAmConfig() } } + + var splitConfig by remember { + mutableStateOf>>( + when { + conf.`interface`.excludedApplications.isNotEmpty() -> + Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet()) + conf.`interface`.includedApplications.isNotEmpty() -> + Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet()) + else -> Pair(SplitOption.ALL, emptySet()) + } + ) + } + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + topTitle = { Text(tunnelConf.name) }, + topTrailing = { + ActionIconButton(Icons.Rounded.Save, R.string.save) { + viewModel.saveSplitTunnelSelection(tunnelId, splitConfig) + } + }, + showBottomItems = true, + ) + ) + } + + SplitTunnelContent( + splitConfig = splitConfig, + installedPackages = splitTunnelState.installedPackages, + onSplitOptionChange = { splitConfig = Pair(it, splitConfig.second) }, + onAppSelectionToggle = { appPackage, enabled -> + val updated = + splitConfig.second.toMutableSet().apply { + if (!enabled) remove(appPackage) else add(appPackage) + } + splitConfig = Pair(splitConfig.first, updated) + }, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt new file mode 100644 index 00000000..d41cf40f --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListItem.kt @@ -0,0 +1,60 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components + +import android.content.pm.PackageManager +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage +import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize + +@Composable +fun AppListItem( + installedPackage: InstalledPackage, + isSelected: Boolean, + onToggle: (Boolean) -> Unit, +) { + val context = LocalContext.current + val icon by remember { + derivedStateOf { + try { + context.packageManager.getApplicationIcon(installedPackage.packageName) + } catch (_: PackageManager.NameNotFoundException) { + AppCompatResources.getDrawable(context, R.drawable.resource_package)!! + } + } + } + + SelectionItemButton( + leading = { + Image( + painter = rememberDrawablePainter(icon), + contentDescription = null, + modifier = Modifier.size(iconSize), + ) + }, + buttonText = installedPackage.name, + description = installedPackage.packageName, + onClick = { onToggle(!isSelected) }, + trailing = { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle(it) }) + } + }, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt similarity index 60% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt index 1bc347da..2b38495d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/AppListSection.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -10,8 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -19,16 +18,32 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption +import java.util.* @Composable fun AppListSection( - apps: List>, - onAppSelectionToggle: (String) -> Unit, - onQueryChange: (String) -> Unit, - query: String, + splitConfig: Pair>, + installedPackages: List, + onAppSelectionToggle: (String, Boolean) -> Unit, ) { + + var query by remember { mutableStateOf("") } + + val filteredAndSortedPackages by remember { + derivedStateOf { + installedPackages + .filter { pkg -> + query.isBlank() || + pkg.name.contains(query, ignoreCase = true) || + pkg.packageName.contains(query, ignoreCase = true) + } + .sortedBy { pkg -> pkg.name.lowercase(Locale.getDefault()) } + } + } + val inputHeight = 45.dp Column( @@ -42,7 +57,7 @@ fun AppListSection( color = MaterialTheme.colorScheme.onBackground ), value = query, - onValueChange = onQueryChange, + onValueChange = { query = it }, interactionSource = remember { MutableInteractionSource() }, label = {}, leading = { Icon(Icons.Outlined.Search, stringResource(R.string.search)) }, @@ -58,14 +73,15 @@ fun AppListSection( ) LazyColumn( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - contentPadding = PaddingValues(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + contentPadding = PaddingValues(top = 24.dp), ) { - items(apps, key = { it.first.`package` }) { app -> + items(filteredAndSortedPackages, key = { it.packageName }) { pkg -> + val selected = splitConfig.second.contains(pkg.packageName) AppListItem( - appInfo = app.first, - isSelected = app.second, - onToggle = { onAppSelectionToggle(app.first.`package`) }, + installedPackage = pkg, + isSelected = selected, + onToggle = { onAppSelectionToggle(pkg.packageName, it) }, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt similarity index 94% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt index 7d424145..603aed84 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitOptionsSelector.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption import java.util.* @Composable diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt similarity index 59% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt index 30b31c67..66ef5896 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -8,15 +8,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption -import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption @Composable fun SplitTunnelContent( - uiState: SplitTunnelUiState, + splitConfig: Pair>, + installedPackages: List, onSplitOptionChange: (SplitOption) -> Unit, - onAppSelectionToggle: (String) -> Unit, - onQueryChange: (String) -> Unit, + onAppSelectionToggle: (String, Boolean) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), @@ -24,15 +24,14 @@ fun SplitTunnelContent( modifier = Modifier.fillMaxWidth().padding(top = 24.dp), ) { SplitOptionSelector( - selectedOption = uiState.splitOption, + selectedOption = splitConfig.first, onOptionChange = onSplitOptionChange, ) - if (uiState.splitOption != SplitOption.ALL) { + if (splitConfig.first != SplitOption.ALL) { AppListSection( - apps = uiState.queriedApps, + installedPackages = installedPackages, onAppSelectionToggle = onAppSelectionToggle, - onQueryChange = onQueryChange, - uiState.searchQuery, + splitConfig = splitConfig, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitOption.kt similarity index 91% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitOption.kt index db474fe2..259b2721 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitOption.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt new file mode 100644 index 00000000..f090a018 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/SplitTunnelUiState.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state + +// data class SplitTunnelUiState( +// val loading: Boolean = true, +// val tunnelConf: TunnelConf? = null, +// val tunneledApps: SplitTunnelApps = emptyList(), +// val queriedApps: SplitTunnelApps = emptyList(), +// val splitOption: SplitOption = SplitOption.ALL, +// val searchQuery: String = "", +// val success: Boolean? = null, +// ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt similarity index 60% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt index f5add72d..f9fdbda5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/state/TunnelApp.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state data class TunnelApp(val name: String, val `package`: String) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt new file mode 100644 index 00000000..ca02c7b2 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt @@ -0,0 +1,116 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.QrCode2 +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider +import com.zaneschepke.wireguardautotunnel.ui.common.button.ActionIconButton +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.Config +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AuthorizationPromptWrapper +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components.* +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel + +@Composable +fun TunnelOptionsScreen(tunnelId: Int, viewModel: TunnelsViewModel) { + val isTv = LocalIsAndroidTV.current + val navController = LocalNavController.current + val sharedViewModel = LocalSharedVm.current + + val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + val tunnelConf by + remember(tunnelsState.tunnels) { + derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId }!! } + } + + var showAuthPrompt by rememberSaveable { mutableStateOf(!isTv) } + var isAuthorized by rememberSaveable { mutableStateOf(isTv) } + var showQrModal by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + sharedViewModel.updateNavbarState( + NavbarState( + showBottomItems = true, + topTitle = { Text(tunnelConf.name) }, + topTrailing = { + Row { + ActionIconButton( + Icons.Rounded.QrCode2, + com.zaneschepke.wireguardautotunnel.R.string.show_qr, + ) { + showQrModal = true + } + ActionIconButton(Icons.Rounded.Edit, R.string.edit_tunnel) { + navController.navigate(Config(tunnelId)) + } + } + }, + ) + ) + } + + if (showQrModal) { + + // Show authorization prompt if needed + if (showAuthPrompt) { + AuthorizationPromptWrapper( + onDismiss = { + showAuthPrompt = false + showQrModal = false + }, + onSuccess = { + showAuthPrompt = false + isAuthorized = true + }, + ) + } + if (isAuthorized) { + QrCodeDialog(tunnelConf = tunnelConf, onDismiss = { showQrModal = false }) + } + } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 24.dp) + .padding(horizontal = 12.dp), + ) { + SurfaceSelectionGroupButton( + items = + listOf( + PrimaryTunnelItem(tunnelConf) { viewModel.togglePrimaryTunnel(tunnelId) }, + AutoTunnelingItem(tunnelConf, navController), + serverIpv4Item(tunnelConf) { viewModel.toggleIpv4Preferred(tunnelId) }, + SplitTunnelingItem(tunnelConf, navController), + ) + ) + if (tunnelsState.isPingEnabled) { + SectionDivider() + SurfaceSelectionGroupButton( + items = + listOf( + pingConfigItem(tunnelConf) { ip -> viewModel.setTunnelPingIp(ip, tunnelId) } + ) + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/AutoTunnelingItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/AutoTunnelingItem.kt similarity index 81% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/AutoTunnelingItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/AutoTunnelingItem.kt index 306405b8..b6f32f92 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/AutoTunnelingItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/AutoTunnelingItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt @@ -7,16 +7,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem { - val navController = LocalNavController.current +fun AutoTunnelingItem(tunnelConf: TunnelConf, navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PrimaryTunnelItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/PrimaryTunnelItem.kt similarity index 65% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PrimaryTunnelItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/PrimaryTunnelItem.kt index 89f3db72..a71abcfa 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PrimaryTunnelItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/PrimaryTunnelItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Star @@ -11,11 +11,9 @@ import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun PrimaryTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun PrimaryTunnelItem(tunnelConf: TunnelConf, onClick: () -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Star, contentDescription = null) }, title = { @@ -31,12 +29,7 @@ fun PrimaryTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): Selectio style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, - trailing = { - ScaledSwitch( - checked = tunnelConf.isPrimaryTunnel, - onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) }, + trailing = { ScaledSwitch(checked = tunnelConf.isPrimaryTunnel, onClick = { onClick() }) }, + onClick = onClick, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/QrCodeDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/QrCodeDialog.kt similarity index 98% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/QrCodeDialog.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/QrCodeDialog.kt index 2d0c6c51..c2722956 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/QrCodeDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/QrCodeDialog.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import androidx.compose.foundation.Image import androidx.compose.foundation.background diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/SplitTunnelingItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/SplitTunnelingItem.kt similarity index 78% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/SplitTunnelingItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/SplitTunnelingItem.kt index c8363ec4..e3d49b83 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/SplitTunnelingItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/SplitTunnelingItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.CallSplit @@ -7,16 +7,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route @Composable -fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem { - val navController = LocalNavController.current +fun SplitTunnelingItem(tunnelConf: TunnelConf, navController: NavController): SelectionItem { return SelectionItem( leading = { Icon(Icons.AutoMirrored.Outlined.CallSplit, contentDescription = null) }, title = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/pingConfigItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/pingConfigItem.kt similarity index 76% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/pingConfigItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/pingConfigItem.kt index 0157bc2c..6cde22c7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/pingConfigItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/pingConfigItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import android.util.Patterns import androidx.compose.foundation.layout.Arrangement @@ -12,11 +12,9 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.textbox.SubmitConfigurationTextBox import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun pingConfigItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun pingConfigItem(tunnelConf: TunnelConf, onSubmit: (ip: String) -> Unit): SelectionItem { return SelectionItem( title = {}, description = { @@ -31,9 +29,7 @@ fun pingConfigItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionIt !Patterns.DOMAIN_NAME.matcher(it).matches() }, supportingText = { Text(stringResource(R.string.ping_target_description)) }, - onSubmit = { ip -> - viewModel.handleEvent(AppEvent.SetTunnelPingTarget(tunnelConf, ip)) - }, + onSubmit = onSubmit, ) } }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/serverIpv4Item.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/serverIpv4Item.kt similarity index 65% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/serverIpv4Item.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/serverIpv4Item.kt index bd7440ac..f2213ce9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/serverIpv4Item.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/components/serverIpv4Item.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Dns @@ -11,11 +11,9 @@ import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun serverIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem { +fun serverIpv4Item(tunnelConf: TunnelConf, onClick: () -> Unit): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Dns, contentDescription = null) }, title = { @@ -31,12 +29,7 @@ fun serverIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionIt style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, - trailing = { - ScaledSwitch( - checked = tunnelConf.isIpv4Preferred, - onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) }, - ) - }, - onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) }, + trailing = { ScaledSwitch(checked = tunnelConf.isIpv4Preferred, onClick = { onClick() }) }, + onClick = onClick, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppUiState.kt deleted file mode 100644 index 5f4c9e57..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppUiState.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import com.zaneschepke.networkmonitor.ConnectivityState -import com.zaneschepke.wireguardautotunnel.data.entity.GeneralState -import com.zaneschepke.wireguardautotunnel.data.mapper.GeneralStateMapper -import com.zaneschepke.wireguardautotunnel.domain.model.AppProxySettings -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.model.AppState -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState - -data class AppUiState( - val appSettings: AppSettings = AppSettings(), - val proxySettings: AppProxySettings = AppProxySettings(), - val tunnels: List = emptyList(), - val activeTunnels: Map = emptyMap(), - val appState: AppState = GeneralStateMapper.toAppState(GeneralState()), - val isAutoTunnelActive: Boolean = false, - val appConfigurationChange: Boolean = false, - val isAppLoaded: Boolean = false, - val connectivityState: ConnectivityState? = null, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppViewState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppViewState.kt deleted file mode 100644 index cadd9286..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AppViewState.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.util.StringValue - -data class AppViewState( - val isConfigChanged: Boolean = false, - val errorMessage: StringValue? = null, - val popBackStack: Boolean = false, - val isAppReady: Boolean = false, - val bottomSheet: BottomSheet = BottomSheet.NONE, - val selectedTunnels: List = emptyList(), - val requestVpnPermission: Boolean = false, - val requestBatteryPermission: Boolean = false, - val showModal: ModalType = ModalType.NONE, -) { - enum class ModalType { - NONE, - DELETE, - INFO, - QR, - } - - enum class BottomSheet { - BACKUP_AND_RESTORE, - EXPORT_TUNNELS, - IMPORT_TUNNELS, - LOGS, - BACKEND, - NONE, - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt new file mode 100644 index 00000000..25069669 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt @@ -0,0 +1,12 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.networkmonitor.ConnectivityState +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings + +data class AutoTunnelUiState( + val autoTunnelActive: Boolean = false, + val connectivityState: ConnectivityState? = null, + val generalSettings: GeneralSettings = GeneralSettings(), + val isBatteryOptimizationShown: Boolean = false, + val stateInitialized: Boolean = false, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt index b2b7df08..4e7181ff 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt @@ -1,9 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.state -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import org.amnezia.awg.config.Config -data class ConfigProxy(val peers: List, val `interface`: InterfaceProxy) { +data class ConfigProxy( + val peers: List = emptyList(), + val `interface`: InterfaceProxy = InterfaceProxy(), +) { fun hasScripts(): Boolean { return `interface`.preUp.isNotBlank() || @@ -29,20 +31,6 @@ data class ConfigProxy(val peers: List, val `interface`: InterfacePro ) } - fun buildTunnelConfFromState(name: String, tunnelConf: TunnelConf?): TunnelConf { - val (wg, am) = buildConfigs() - return tunnelConf?.copyWithCallback( - tunName = name, - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - ?: TunnelConf( - tunName = name, - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - } - companion object { fun from(amConfig: Config): ConfigProxy { return ConfigProxy( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt index d07f7e58..43dc1577 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/InterfaceProxy.kt @@ -1,8 +1,10 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.wireguard.config.Interface +import com.zaneschepke.wireguardautotunnel.util.extensions.ifNotBlank import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList +import java.util.* data class InterfaceProxy( val privateKey: String = "", @@ -11,8 +13,8 @@ data class InterfaceProxy( val dnsServers: String = "", val listenPort: String = "", val mtu: String = "", - val includedApplications: MutableSet = mutableSetOf(), - val excludedApplications: MutableSet = mutableSetOf(), + val includedApplications: Set = emptySet(), + val excludedApplications: Set = emptySet(), val junkPacketCount: String = "", val junkPacketMinSize: String = "", val junkPacketMaxSize: String = "", @@ -22,6 +24,15 @@ data class InterfaceProxy( val responsePacketMagicHeader: String = "", val underloadPacketMagicHeader: String = "", val transportPacketMagicHeader: String = "", + val i1: String = "", + val i2: String = "", + val i3: String = "", + val i4: String = "", + val i5: String = "", + val j1: String = "", + val j2: String = "", + val j3: String = "", + val itime: String = "", val preUp: String = "", val postUp: String = "", val preDown: String = "", @@ -33,9 +44,9 @@ data class InterfaceProxy( .apply { parseAddresses(addresses) parsePrivateKey(privateKey) - if (dnsServers.isNotBlank()) parseDnsServers(dnsServers) - if (mtu.isNotBlank()) parseMtu(mtu) - if (listenPort.isNotBlank()) parseListenPort(listenPort) + dnsServers.ifNotBlank { parseDnsServers(it) } + listenPort.ifNotBlank { parseListenPort(it) } + mtu.ifNotBlank { parseMtu(it) } includeApplications(includedApplications) excludeApplications(excludedApplications) preUp.toTrimmedList().forEach { parsePreUp(it) } @@ -46,6 +57,30 @@ data class InterfaceProxy( .build() } + fun isAmneziaEnabled(): Boolean { + return listOf( + junkPacketCount, + junkPacketMinSize, + junkPacketMaxSize, + initPacketJunkSize, + responsePacketJunkSize, + initPacketMagicHeader, + responsePacketMagicHeader, + underloadPacketMagicHeader, + transportPacketMagicHeader, + i1, + i2, + i3, + i4, + i5, + j1, + j2, + j3, + itime, + ) + .any { it.isNotBlank() } + } + fun toAmneziaCompatibilityConfig(): InterfaceProxy { return copy( junkPacketCount = "4", @@ -57,6 +92,15 @@ data class InterfaceProxy( responsePacketMagicHeader = "2", underloadPacketMagicHeader = "3", transportPacketMagicHeader = "4", + i1 = "", + i2 = "", + i3 = "", + i4 = "", + i5 = "", + j1 = "", + j2 = "", + j3 = "", + itime = "", ) } @@ -71,69 +115,241 @@ data class InterfaceProxy( responsePacketMagicHeader = "", underloadPacketMagicHeader = "", transportPacketMagicHeader = "", + i1 = "", + i2 = "", + i3 = "", + i4 = "", + i5 = "", + j1 = "", + j2 = "", + j3 = "", + itime = "", ) } + // TODO fix this later when we get amnezia to properly return 0 fun isAmneziaCompatibilityModeSet(): Boolean { - return with(initPacketJunkSize.toIntOrNull()) { this == 0 || this == null } && - with(responsePacketJunkSize.toIntOrNull()) { this == 0 || this == null } && + return (initPacketJunkSize.toIntOrNull() ?: 0) == 0 && + (responsePacketJunkSize.toIntOrNull() ?: 0) == 0 && initPacketMagicHeader.toLongOrNull() == 1L && responsePacketMagicHeader.toLongOrNull() == 2L && underloadPacketMagicHeader.toLongOrNull() == 3L && transportPacketMagicHeader.toLongOrNull() == 4L } + fun isCompatibleWithStandardWireGuard(): Boolean { + return isAmneziaCompatibilityModeSet() + } + fun toAmInterface(): org.amnezia.awg.config.Interface { return org.amnezia.awg.config.Interface.Builder() .apply { parseAddresses(addresses) parsePrivateKey(privateKey) - if (dnsServers.isNotBlank()) parseDnsServers(dnsServers) - if (mtu.isNotBlank()) parseMtu(mtu) - if (listenPort.isNotBlank()) parseListenPort(listenPort) + dnsServers.ifNotBlank { parseDnsServers(it) } + listenPort.ifNotBlank { parseListenPort(it) } + mtu.ifNotBlank { parseMtu(it) } includeApplications(includedApplications) excludeApplications(excludedApplications) preUp.toTrimmedList().forEach { parsePreUp(it) } postUp.toTrimmedList().forEach { parsePostUp(it) } preDown.toTrimmedList().forEach { parsePreDown(it) } postDown.toTrimmedList().forEach { parsePostDown(it) } - if (junkPacketCount.isNotBlank()) parseJunkPacketCount(junkPacketCount) - if (junkPacketMinSize.isNotBlank()) parseJunkPacketMinSize(junkPacketMinSize) - if (junkPacketMaxSize.isNotBlank()) parseJunkPacketMaxSize(junkPacketMaxSize) - if (initPacketJunkSize.isNotBlank()) parseInitPacketJunkSize(initPacketJunkSize) - if (responsePacketJunkSize.isNotBlank()) - parseResponsePacketJunkSize(responsePacketJunkSize) - if (initPacketMagicHeader.isNotBlank()) - parseInitPacketMagicHeader(initPacketMagicHeader) - if (responsePacketMagicHeader.isNotBlank()) - parseResponsePacketMagicHeader(responsePacketMagicHeader) - if (underloadPacketMagicHeader.isNotBlank()) - parseUnderloadPacketMagicHeader(underloadPacketMagicHeader) - if (transportPacketMagicHeader.isNotBlank()) - parseTransportPacketMagicHeader(transportPacketMagicHeader) + junkPacketCount.ifNotBlank { parseJunkPacketCount(it) } + junkPacketMinSize.ifNotBlank { parseJunkPacketMinSize(it) } + junkPacketMaxSize.ifNotBlank { parseJunkPacketMaxSize(it) } + initPacketJunkSize.ifNotBlank { parseInitPacketJunkSize(it) } + responsePacketJunkSize.ifNotBlank { parseResponsePacketJunkSize(it) } + initPacketMagicHeader.ifNotBlank { parseInitPacketMagicHeader(it) } + responsePacketMagicHeader.ifNotBlank { parseResponsePacketMagicHeader(it) } + underloadPacketMagicHeader.ifNotBlank { parseUnderloadPacketMagicHeader(it) } + transportPacketMagicHeader.ifNotBlank { parseTransportPacketMagicHeader(it) } + i1.ifNotBlank { parseI1(it) } + i2.ifNotBlank { parseI2(it) } + i3.ifNotBlank { parseI3(it) } + i4.ifNotBlank { parseI4(it) } + i5.ifNotBlank { parseI5(it) } + j1.ifNotBlank { parseJ1(it) } + j2.ifNotBlank { parseJ2(it) } + j3.ifNotBlank { parseJ3(it) } + itime.ifNotBlank { parseItime(it) } } .build() } + fun getValidationErrors(): List { + val errors = mutableListOf() + + if (privateKey.isBlank()) { + errors.add("Private key is required") + } else if (!isValidBase64(privateKey) || privateKey.length != 44) { + errors.add("Invalid private key format (must be 44-character Base64)") + } + + // Addresses validation (basic) + if (addresses.isBlank()) { + errors.add("Addresses are required") + } // More detailed CIDR validation can be added if needed + + listenPort.ifNotBlank { + val port = it.toIntOrNull() + if (port == null) errors.add("Listen port must be an integer") + else if (port !in 1..65535) errors.add("Listen port must be between 1 and 65535") + } + + mtu.ifNotBlank { + val mtuValue = it.toIntOrNull() + if (mtuValue == null) errors.add("MTU must be an integer") + else if (mtuValue !in 576..9200) errors.add("MTU should be between 576 and 9200") + } + + junkPacketCount.ifNotBlank { + val count = it.toIntOrNull() + if (count == null) errors.add("Junk packet count must be an integer") + else if (count !in 1..128) errors.add("Junk packet count must be between 0 and 128") + } + + junkPacketMinSize.ifNotBlank { + val min = it.toIntOrNull() + if (min == null) errors.add("Junk packet min size must be an integer") + else if (min !in 1..1279) errors.add("Junk packet min size must be between 1 and 1279") + } + + junkPacketMaxSize.ifNotBlank { + val max = it.toIntOrNull() + if (max == null) errors.add("Junk packet max size must be an integer") + else if (max !in 2..1280) errors.add("Junk packet max size must be between 2 and 1280") + } + + if (junkPacketMinSize.isNotBlank() && junkPacketMaxSize.isNotBlank()) { + val min = junkPacketMinSize.toIntOrNull() ?: 0 + val max = junkPacketMaxSize.toIntOrNull() ?: 0 + if (min >= max) errors.add("Junk packet min size must be less than max size") + } + + initPacketJunkSize.ifNotBlank { + val size = it.toIntOrNull() + if (size == null) errors.add("Init packet junk size must be an integer") + else if (size !in 0..64) errors.add("Init packet junk size must be between 0 and 64") + } + + responsePacketJunkSize.ifNotBlank { + val size = it.toIntOrNull() + if (size == null) errors.add("Response packet junk size must be an integer") + else if (size !in 0..64) + errors.add("Response packet junk size must be between 0 and 64") + } + + val h1 = initPacketMagicHeader.toIntOrNull() + val h2 = responsePacketMagicHeader.toIntOrNull() + val h3 = underloadPacketMagicHeader.toIntOrNull() + val h4 = transportPacketMagicHeader.toIntOrNull() + val headers = listOf(h1, h2, h3, h4) + if (headers.any { it != null }) { + if (headers.any { it == null }) + errors.add("All magic headers must be set if any is set") + else { + val hs = headers.filterNotNull() + if (hs.any { it !in 1..4 }) errors.add("Magic headers must be between 1 and 4") + if (hs.distinct().size != 4) + errors.add("Magic headers must be a unique permutation of 1-4") + } + } + + fun validateHexBlob(field: String?, name: String) { + field?.let { + if (!it.matches(Regex("^$"))) { + errors.add("$name must be in format ") + } + } + } + + validateHexBlob(i1, "i1") + validateHexBlob(i2, "i2") + validateHexBlob(i3, "i3") + validateHexBlob(i4, "i4") + validateHexBlob(i5, "i5") + validateHexBlob(j1, "j1") + validateHexBlob(j2, "j2") + validateHexBlob(j3, "j3") + + itime.ifNotBlank { + val time = it.toIntOrNull() + if (time == null) errors.add("itime must be an integer") + else if (time < 0) errors.add("itime must be non-negative") + } + + return errors + } + + /** + * Mimics QUIC (HTTP/3) traffic by setting i1 to a QUIC initial packet and i2 to a follow-up + * frame. Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ + fun setQuicMimic(): InterfaceProxy { + return copy( + i1 = + "", + i2 = "", + j1 = "", + itime = "120", + ) + } + + /** + * Mimics DNS query traffic with a single i1 packet. DNS is typically single-packet, so no + * additional i/j packets are set. Compatible with standard WireGuard servers. + */ + fun setDnsMimic(): InterfaceProxy { + return copy( + i1 = "", + i2 = "", + j1 = "", + itime = "120", + ) + } + + /** + * Mimics SIP (VoIP) traffic with i1 as an INVITE packet and i2 as a follow-up (e.g., TRYING + * response). Adds j1 for extra obfuscation. Compatible with standard WireGuard servers. + */ + fun setSipMimic(): InterfaceProxy { + return copy( + i1 = + "", + i2 = + "", + j1 = "", + itime = "120", + ) + } + + private fun isValidBase64(str: String): Boolean { + return try { + Base64.getDecoder().decode(str) + true + } catch (e: Exception) { + false + } + } + companion object { fun from(i: Interface): InterfaceProxy { + val dnsString = + listOf( + i.dnsServers.joinToString(", ").replace("/", "").trim(), + i.dnsSearchDomains.joinAndTrim(), + ) + .filter { it.isNotEmpty() } + .joinToString(", ") + .takeIf { it.isNotBlank() } return InterfaceProxy( publicKey = i.keyPair.publicKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(), addresses = i.addresses.joinToString(", ").trim(), - dnsServers = - listOf( - i.dnsServers.joinToString(", ").replace("/", "").trim(), - i.dnsSearchDomains.joinAndTrim(), - ) - .filter { it.isNotEmpty() } - .joinToString(", "), + dnsServers = dnsString ?: "", listenPort = - if (i.listenPort.isPresent) { - i.listenPort.get().toString().trim() - } else { - "" - }, + if (i.listenPort.isPresent) i.listenPort.get().toString().trim() else "", mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", includedApplications = i.includedApplications.toMutableSet(), excludedApplications = i.excludedApplications.toMutableSet(), @@ -145,18 +361,19 @@ data class InterfaceProxy( } fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy { + val dnsString = + (i.dnsServers + i.dnsSearchDomains) + .joinToString(", ") + .replace("/", "") + .trim() + .takeIf { it.isNotBlank() } return InterfaceProxy( publicKey = i.keyPair.publicKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(), addresses = i.addresses.joinToString(", ").trim(), - dnsServers = - (i.dnsServers + i.dnsSearchDomains).joinToString(", ").replace("/", "").trim(), + dnsServers = dnsString ?: "", listenPort = - if (i.listenPort.isPresent) { - i.listenPort.get().toString().trim() - } else { - "" - }, + if (i.listenPort.isPresent) i.listenPort.get().toString().trim() else "", mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", includedApplications = i.includedApplications.toMutableSet(), excludedApplications = i.excludedApplications.toMutableSet(), @@ -165,59 +382,42 @@ data class InterfaceProxy( preDown = i.preDown.joinAndTrim(), postDown = i.postDown.joinAndTrim(), junkPacketCount = - if (i.junkPacketCount.isPresent) { - i.junkPacketCount.get().toString() - } else { - "" - }, + if (i.junkPacketCount.isPresent) i.junkPacketCount.get().toString() else "", junkPacketMinSize = - if (i.junkPacketMinSize.isPresent) { - i.junkPacketMinSize.get().toString() - } else { - "" - }, + if (i.junkPacketMinSize.isPresent) i.junkPacketMinSize.get().toString() else "", junkPacketMaxSize = - if (i.junkPacketMaxSize.isPresent) { - i.junkPacketMaxSize.get().toString() - } else { - "" - }, + if (i.junkPacketMaxSize.isPresent) i.junkPacketMaxSize.get().toString() else "", initPacketJunkSize = - if (i.initPacketJunkSize.isPresent) { - i.initPacketJunkSize.get().toString() - } else { - "" - }, + if (i.initPacketJunkSize.isPresent) i.initPacketJunkSize.get().toString() + else "", responsePacketJunkSize = - if (i.responsePacketJunkSize.isPresent) { + if (i.responsePacketJunkSize.isPresent) i.responsePacketJunkSize.get().toString() - } else { - "" - }, + else "", initPacketMagicHeader = - if (i.initPacketMagicHeader.isPresent) { - i.initPacketMagicHeader.get().toString() - } else { - "" - }, + if (i.initPacketMagicHeader.isPresent) i.initPacketMagicHeader.get().toString() + else "", responsePacketMagicHeader = - if (i.responsePacketMagicHeader.isPresent) { + if (i.responsePacketMagicHeader.isPresent) i.responsePacketMagicHeader.get().toString() - } else { - "" - }, - transportPacketMagicHeader = - if (i.transportPacketMagicHeader.isPresent) { - i.transportPacketMagicHeader.get().toString() - } else { - "" - }, + else "", underloadPacketMagicHeader = - if (i.underloadPacketMagicHeader.isPresent) { + if (i.underloadPacketMagicHeader.isPresent) i.underloadPacketMagicHeader.get().toString() - } else { - "" - }, + else "", + transportPacketMagicHeader = + if (i.transportPacketMagicHeader.isPresent) + i.transportPacketMagicHeader.get().toString() + else "", + i1 = if (i.i1.isPresent) i.i1.get() else "", + i2 = if (i.i2.isPresent) i.i2.get() else "", + i3 = if (i.i3.isPresent) i.i3.get() else "", + i4 = if (i.i4.isPresent) i.i4.get() else "", + i5 = if (i.i5.isPresent) i.i5.get() else "", + j1 = if (i.j1.isPresent) i.j1.get() else "", + j2 = if (i.j2.isPresent) i.j2.get() else "", + j3 = if (i.j3.isPresent) i.j3.get() else "", + itime = if (i.itime.isPresent) i.itime.get().toString() else "", ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LoggerUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LoggerUiState.kt new file mode 100644 index 00000000..f3cce27e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/LoggerUiState.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.logcatter.model.LogMessage + +data class LoggerUiState(val messages: List = emptyList()) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavBarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavBarState.kt deleted file mode 100644 index 8f23bfee..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavBarState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import androidx.compose.runtime.Composable -import com.zaneschepke.wireguardautotunnel.ui.Route - -data class NavBarState( - val showTop: Boolean = true, - val showBottom: Boolean = true, - val topTitle: @Composable (() -> Unit)? = null, - val topTrailing: @Composable (() -> Unit)? = null, - val route: Route? = null, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavbarState.kt new file mode 100644 index 00000000..119b12ff --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/NavbarState.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import androidx.compose.runtime.Composable + +data class NavbarState( + val topTitle: (@Composable () -> Unit)? = null, + val topTrailing: (@Composable () -> Unit)? = null, + val showBottomItems: Boolean = false, + val removeBottom: Boolean = false, + val removeTop: Boolean = false, + val locationDisclosureShown: Boolean = false, + val isAutoTunnelActive: Boolean = false, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt new file mode 100644 index 00000000..250b9815 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ProxySettingsUiState.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.AppProxySettings + +data class ProxySettingsUiState( + val proxySettings: AppProxySettings = AppProxySettings(), + val isSocks5BindAddressError: Boolean = false, + val isHttpBindAddressError: Boolean = false, + val isUserNameError: Boolean = false, + val isPasswordError: Boolean = false, + val passwordVisible: Boolean = false, + val stateInitialized: Boolean = false, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SettingUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SettingUiState.kt new file mode 100644 index 00000000..05e7faa9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SettingUiState.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings + +data class SettingUiState( + val settings: GeneralSettings = GeneralSettings(), + val isLocalLoggingEnabled: Boolean = false, + val remoteKey: String? = null, + val isRemoteEnabled: Boolean = false, + val isPinLockEnabled: Boolean = false, + val showDetailedPingStats: Boolean = false, + val stateInitialized: Boolean = false, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt new file mode 100644 index 00000000..d0821945 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt @@ -0,0 +1,17 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings +import com.zaneschepke.wireguardautotunnel.ui.theme.Theme +import com.zaneschepke.wireguardautotunnel.util.LocaleUtil + +data class SharedAppUiState( + val isAppLoaded: Boolean = false, + val theme: Theme = Theme.AUTOMATIC, + val locale: String = LocaleUtil.OPTION_PHONE_LANGUAGE, + val pinLockEnabled: Boolean = false, + val isAuthorized: Boolean = false, + val isAutoTunnelActive: Boolean = false, + val isLocationDisclosureShown: Boolean = false, + val settings: GeneralSettings = GeneralSettings(), + val navBarState: NavbarState = NavbarState(), +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt new file mode 100644 index 00000000..66b47c94 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt @@ -0,0 +1,10 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf + +data class SplitTunnelUiState( + val installedPackages: List = emptyList(), + val stateInitialized: Boolean = false, + val tunnels: List = emptyList(), +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SupportUiState.kt similarity index 51% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SupportUiState.kt index fc94a0e4..b8df93b9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SupportUiState.kt @@ -1,12 +1,9 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.support +package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate -import com.zaneschepke.wireguardautotunnel.util.StringValue data class SupportUiState( val appUpdate: AppUpdate? = null, val isLoading: Boolean = false, - val error: StringValue? = null, - val isUptoDate: Boolean? = null, val downloadProgress: Float = 0f, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt new file mode 100644 index 00000000..47d2afd3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState + +data class TunnelsUiState( + val tunnels: List = listOf(), + val selectedTunnels: Set = setOf(), + val activeTunnels: Map = emptyMap(), + val isPingEnabled: Boolean = false, + val appMode: AppMode = AppMode.VPN, + val isWildcardsEnabled: Boolean = false, + val showPingStats: Boolean = false, + val stateInitialized: Boolean = false, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt index 8a222c8b..7676bf22 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV +import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV private val DarkColorScheme = darkColorScheme( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt index d62f1309..85a91454 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt @@ -1,24 +1,11 @@ package com.zaneschepke.wireguardautotunnel.util object Constants { - - const val MAX_LOG_SIZE = 10_000L const val BASE_PACKAGE = "com.zaneschepke.wireguardautotunnel" const val BASE_LOG_FILE_NAME = "wg_tunnel_logs" - const val MANUAL_TUNNEL_CONFIG_ID = 0 const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes - - const val CONF_FILE_EXTENSION = ".conf" - const val ZIP_FILE_EXTENSION = ".zip" - const val URI_CONTENT_SCHEME = "content" - private const val TEXT_MIME_TYPE = "text/plain" - const val ZIP_FILE_MIME_TYPE = "application/zip" - const val ALLOWED_TV_FILE_TYPES = "${TEXT_MIME_TYPE}|${ZIP_FILE_MIME_TYPE}" - const val ALL_FILE_TYPES = "*/*" - const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs" - const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS" const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 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 04ba1090..92d46519 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt @@ -8,97 +8,127 @@ import android.os.Build import android.provider.MediaStore import android.provider.OpenableColumns import androidx.annotation.RequiresApi -import androidx.core.content.FileProvider -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig +import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri -import com.zaneschepke.wireguardautotunnel.util.extensions.installApk -import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile -import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString import java.io.* import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext -import org.amnezia.awg.config.Config import timber.log.Timber class FileUtils(private val context: Context, private val ioDispatcher: CoroutineDispatcher) { - suspend fun createWgFiles(tunnels: List): List = + /** + * Creates a configuration file for a given tunnel if the config string is valid. + * + * @param fileName The name of the file. + * @param data THe string data for the file. + * @return The created File if successful and non-empty, else null. + * @throws IOException If file creation or writing fails (e.g., no space or permissions). + */ + suspend fun createFile(fileName: String, data: String): File? = withContext(ioDispatcher) { - tunnels.mapNotNull { config -> - if (config.wgQuick.isBlank()) { - Timber.w("Skipping tunnel ${config.tunName}: empty wgQuick config") - return@mapNotNull null - } - val file = File(context.cacheDir, "${config.tunName}-wg.conf") - file.outputStream().use { it.write(config.wgQuick.toByteArray()) } - Timber.d("Created WG file: ${file.path}, size: ${file.length()} bytes") - if (file.length() == 0L) { - Timber.w("WG file ${file.path} is empty") - null - } else { - file - } + val file = File(context.cacheDir, "${fileName}.conf") + file.outputStream().use { it.write(data.toByteArray()) } + Timber.d("Created file: ${file.path}, size: ${file.length()} bytes") + + if (file.length() == 0L) { + Timber.w("Config file ${file.path} is empty") + return@withContext null } - } - - suspend fun createAmFiles(tunnels: List): List = - withContext(ioDispatcher) { - tunnels - .filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT && it.amQuick.isNotBlank() } - .mapNotNull { config -> - val file = File(context.cacheDir, "${config.tunName}-am.conf") - file.outputStream().use { it.write(config.amQuick.toByteArray()) } - Timber.d("Created AM file: ${file.path}, size: ${file.length()} bytes") - if (file.length() == 0L) { - Timber.w("AM file ${file.path} is empty") - null - } else { - file - } - } - } - - suspend fun zipAll(zipFile: File, files: List) = - withContext(ioDispatcher) { - ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> - files.forEach { file -> - if (!file.exists() || file.length() == 0L) { - Timber.w("Skipping file ${file.path}: does not exist or empty") - return@forEach - } - val entryName = file.name // Use file name only, avoid complex path logic - val entry = ZipEntry(entryName) - zos.putNextEntry(entry) - FileInputStream(file).use { fis -> - fis.copyTo(zos) - Timber.d( - "Added ${file.path} to zip as $entryName, size: ${file.length()} bytes" - ) - } - zos.closeEntry() - } - zos.flush() - Timber.d("Finished zipping: ${zipFile.path}, size: ${zipFile.length()} bytes") - } - } - - suspend fun createNewShareFile(name: String): File = - withContext(ioDispatcher) { - val sharePath = File(context.filesDir, "external_files") - if (sharePath.exists()) sharePath.deleteRecursively() - sharePath.mkdirs() - val file = File(sharePath, name) - if (file.exists()) file.delete() - file.createNewFile() - Timber.d("Created share file: ${file.path}") file } + /** + * Zips a list of files into a single ZIP file. + * + * @param zipFile The target ZIP file to create or overwrite. + * @param files The list of files to include in the ZIP. + * @return Result.success(Unit) on success, or Result.failure with the exception on error. + */ + suspend fun zipAll(zipFile: File, files: List): Result = + withContext(ioDispatcher) { + runCatching { + if (zipFile.exists() && !zipFile.delete()) { + throw IOException("Failed to delete existing ZIP file: ${zipFile.path}") + } + if (!zipFile.createNewFile()) { + throw IOException("Failed to create ZIP file: ${zipFile.path}") + } + + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> + files.forEach { file -> + if (!file.exists() || file.length() == 0L) { + Timber.w("Skipping file ${file.path}: does not exist or empty") + return@forEach + } + val entryName = file.name + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + FileInputStream(file).use { fis -> + fis.copyTo(zos) + Timber.d( + "Added ${file.path} to zip as $entryName, size: ${file.length()} bytes" + ) + } + zos.closeEntry() + } + zos.flush() + Timber.d( + "Finished zipping: ${zipFile.path}, size: ${zipFile.length()} bytes" + ) + } + } + .onFailure { error -> + Timber.e(error, "Error zipping files into ${zipFile.path}") + if (zipFile.exists()) zipFile.delete() + } + } + + suspend fun createNewShareFile(name: String): Result = + withContext(ioDispatcher) { + runCatching { + val cacheDir = + context.cacheDir ?: throw IOException("Cache directory unavailable") + val sharePath = File(cacheDir, "external_files") + if (!sharePath.exists() && !sharePath.mkdirs()) { + throw IOException("Failed to create share directory: ${sharePath.path}") + } + val file = File(sharePath, name) + if (file.exists() && !file.delete()) { + throw IOException("Failed to delete existing file: ${file.path}") + } + if (!file.createNewFile()) { + throw IOException("Failed to create new file: ${file.path}") + } + Timber.d("Created share file: ${file.path}") + file + } + .onFailure { error -> Timber.e(error, "Error creating share file") } + } + + suspend fun exportFile(file: File, uri: Uri?, mimeType: String): Result { + return if (uri != null) { + copyFileToUri(file, uri).map { uri } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + runCatching { + saveToDownloadsWithMediaStore(file, mimeType) + ?: throw IOException("Failed to insert into MediaStore") + } + } else { + Result.failure( + IOException( + "Auto-export to Downloads not supported on this device (pre-Android 10). Use a file picker (SAF) to provide a URI." + ) + ) + } + } + } + suspend fun copyFileToUri(sourceFile: File, destinationUri: Uri): Result = withContext(ioDispatcher) { try { @@ -155,16 +185,12 @@ class FileUtils(private val context: Context, private val ioDispatcher: Coroutin return cursor.getString(index) } - private fun isValidUriContentScheme(uri: Uri): Boolean { - return uri.scheme == Constants.URI_CONTENT_SCHEME - } - private fun getFileName(uri: Uri): String { return getFileNameByCursor(uri) ?: NumberUtils.generateRandomTunnelName() } private fun getNameFromFileName(fileName: String): String { - return fileName.substring(0, fileName.lastIndexOf('.')) + return fileName.take(fileName.lastIndexOf('.')) } private fun getFileExtensionFromFileName(fileName: String): String? { @@ -182,54 +208,59 @@ class FileUtils(private val context: Context, private val ioDispatcher: Coroutin } } - suspend fun buildTunnelsFromUri(uri: Uri): List = + /** + * Reads configuration data from a URI pointing to a .conf file or .zip archive containing .conf + * files. + * + * @param uri The URI to the file or archive. + * @return Result.success with a Map where keys are config names (without .conf extension) and + * values are the raw string contents, or Result.failure with an exception (e.g., + * FileNotFoundException, IOException). + */ + suspend fun readConfigsFromUri(uri: Uri): Result> = withContext(ioDispatcher) { - if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException - val fileName = getFileName(uri) - when (getFileExtensionFromFileName(fileName)) { - Constants.CONF_FILE_EXTENSION -> { - context.getInputStreamFromUri(uri)?.use { inputStream -> - val name = getNameFromFileName(fileName) - val amConf = Config.parse(inputStream) - listOf( - TunnelConf( - tunName = name, - wgQuick = amConf.toWgQuickString(), - amQuick = amConf.toAwgQuickString(true, false), - ) - ) - } ?: throw FileReadException - } - Constants.ZIP_FILE_EXTENSION -> { - ZipInputStream(context.getInputStreamFromUri(uri)).use { zip -> - generateSequence { zip.nextEntry } - .filterNot { - it.isDirectory || - getFileExtensionFromFileName(it.name) != - Constants.CONF_FILE_EXTENSION + runCatching { + val fileName = getFileName(uri).lowercase() + val extension = getFileExtensionFromFileName(fileName) + val inputStream = + context.getInputStreamFromUri(uri) + ?: throw IOException("Failed to open input stream for URI: $uri") + + when (extension?.lowercase()) { + CONF_FILE_EXTENSION.lowercase() -> { + inputStream.use { stream -> + val content = stream.bufferedReader().readText() + val name = getNameFromFileName(fileName) + mapOf(content to name) } - .map { entry -> - val name = getNameFromFileName(entry.name) - val amConf = Config.parse(zip.bufferedReader()) - TunnelConf( - tunName = name, - wgQuick = amConf.toWgQuickString(), - amQuick = amConf.toAwgQuickString(true, false), - ) + } + ZIP_FILE_EXTENSION.lowercase() -> { + ZipInputStream(inputStream).use { zip -> + val configs = mutableMapOf() + var entry: ZipEntry? = zip.nextEntry + while (entry != null) { + if ( + !entry.isDirectory && + getFileExtensionFromFileName(entry.name.lowercase()) == + CONF_FILE_EXTENSION.lowercase() + ) { + val content = zip.bufferedReader().readText() + val name = getNameFromFileName(entry.name) + configs[content] = name + } + zip.closeEntry() + entry = zip.nextEntry + } + configs } - .toList() + } + else -> + throw FileNotFoundException("Unsupported file extension: $extension") } } - else -> throw InvalidFileExtensionException - } + .onFailure { error -> Timber.e(error, "Error reading configs from URI: $uri") } } - fun shareFile(shareFile: File) { - val uri = - FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile) - context.launchShareFile(uri) - } - @RequiresApi(Build.VERSION_CODES.Q) suspend fun saveToDownloadsWithMediaStore(file: File, mimeType: String): Uri? = withContext(ioDispatcher) { @@ -255,7 +286,17 @@ class FileUtils(private val context: Context, private val ioDispatcher: Coroutin return@withContext itemUri } - fun installApk(file: File) { - context.installApk(file) + companion object { + const val CONF_FILE_EXTENSION = ".conf" + const val ZIP_FILE_EXTENSION = ".zip" + private const val TEXT_MIME_TYPE = "text/plain" + const val ZIP_FILE_MIME_TYPE = "application/zip" + const val ALL_FILE_TYPES = "*/*" + + const val ALLOWED_TV_FILE_TYPES = "${TEXT_MIME_TYPE}|${ZIP_FILE_MIME_TYPE}" + + const val URI_CONTENT_SCHEME = "content" + const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs" + const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt index 70861d81..d5488b49 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt @@ -6,6 +6,7 @@ import android.content.ComponentName import android.content.Context import android.content.Context.POWER_SERVICE import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.net.Uri @@ -20,9 +21,12 @@ import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.TunnelApp import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.FileUtils import java.io.File import java.io.InputStream +import java.util.* import kotlin.system.exitProcess import timber.log.Timber @@ -44,12 +48,6 @@ fun Context.isBatteryOptimizationsDisabled(): Boolean { return pm.isIgnoringBatteryOptimizations(packageName) } -val Context.actionBarSize - get() = - theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)).let { attrs -> - attrs.getDimension(0, 0F).toInt().also { attrs.recycle() } - } - fun Context.launchNotificationSettings() { if (isRunningOnTv()) return launchAppSettings() val settingsIntent: Intent = @@ -85,8 +83,8 @@ fun Context.hasSAFSupport(mimeType: String): Boolean { isRunningOnTv() && activitiesToResolveIntent.all { val name = it.activityInfo.packageName - name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || - name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) + name.startsWith(FileUtils.GOOGLE_TV_EXPLORER_STUB) || + name.startsWith(FileUtils.ANDROID_TV_EXPLORER_STUB) } ) { Timber.w("Only stub file explorers found on TV") @@ -96,11 +94,12 @@ fun Context.hasSAFSupport(mimeType: String): Boolean { } } -fun Context.launchShareFile(file: Uri) { +fun Context.launchShareFile(file: File) { + val uri = FileProvider.getUriForFile(this, getString(R.string.provider), file) val shareIntent = Intent().apply { action = Intent.ACTION_SEND - type = Constants.ALL_FILE_TYPES + type = FileUtils.ALL_FILE_TYPES putExtra(Intent.EXTRA_STREAM, file) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -207,6 +206,18 @@ fun Context.getAllInternetCapablePackages(): List { } } +fun Context.getSplitTunnelApps(): List { + val packages = getAllInternetCapablePackages() + return packages + .filter { it.applicationInfo != null } + .map { pkg -> + TunnelApp( + packageManager.getApplicationLabel(pkg.applicationInfo!!).toString(), + pkg.packageName, + ) + } +} + fun Context.canInstallPackages(): Boolean { return packageManager.canRequestPackageInstalls() } @@ -241,3 +252,22 @@ fun MainActivity.restartApp() { exitProcess(0) } } + +fun PackageManager.getFriendlyAppName(packageName: String, appInfo: ApplicationInfo): String { + val label = + try { + getApplicationLabel(appInfo).toString() + } catch (e: PackageManager.NameNotFoundException) { + packageName + } + + // probably a package, parse it + return if (label.count { it == '.' } > 1) { + val lastSegment = label.substringAfterLast(".", label) + lastSegment.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + } else { + label + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt index 26b56c36..6ad3e668 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt @@ -1,20 +1,28 @@ package com.zaneschepke.wireguardautotunnel.util.extensions -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch fun Flow>.distinctByKeys(): Flow> { return distinctUntilChanged { old, new -> old.keys == new.keys } } -fun Flow.zipWithPrevious(): Flow> = flow { - var previous: T? = null - collect { current -> - emit(previous to current) - previous = current +fun debounce( + scope: CoroutineScope, + delayMillis: Long = 300L, + onDebounced: (T) -> Unit, +): (T) -> Unit { + var job: Job? = null + return { param: T -> + job?.cancel() + job = + scope.launch { + delay(delayMillis) + onDebounced(param) + } } } - -suspend fun StateFlow.withFirstState(block: suspend (AppUiState) -> R): R { - return block(first { it.isAppLoaded }) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt index 3f3a535b..2536b271 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/Extensions.kt @@ -15,17 +15,15 @@ fun BigDecimal.toThreeDecimalPlaceString(): String { 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 Tunnels = List typealias TunnelConfigs = List typealias Packages = List -fun MutableList.addAllUnique(elements: Collection, comparator: (T, T) -> Boolean) { - addAll(elements.filterNot { new -> this.any { existing -> comparator(existing, new) } }) -} +typealias TunnelName = String? + +typealias QuickConfig = String fun > List.isSortedBy(selector: (T) -> R): Boolean { return zipWithNext().all { (a, b) -> selector(a) <= selector(b) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/RepositoryExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/RepositoryExtensions.kt new file mode 100644 index 00000000..696b2deb --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/RepositoryExtensions.kt @@ -0,0 +1,43 @@ +package com.zaneschepke.wireguardautotunnel.util.extensions + +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository + +suspend fun TunnelRepository.saveTunnelsUniquely( + tunnels: List, + existing: List, +) { + val uniqueTunnels = generateUniquelyNamedConfigs(tunnels, existing) + saveAll(uniqueTunnels) +} + +private fun generateUniquelyNamedConfigs( + incoming: List, + existing: List, +): List { + val usedNames = existing.map { it.tunName }.toMutableSet() + val result = mutableListOf() + val regex = Regex("(.+)\\s*\\((\\d+)\\)$") + + for (tun in incoming) { + var baseName = tun.tunName + var uniqueName = tun.tunName + var counter = 1 + + val matchResult = regex.find(baseName) + if (matchResult != null) { + baseName = matchResult.groupValues[1].trimEnd() + counter = matchResult.groupValues[2].toIntOrNull()?.plus(1) ?: 1 + uniqueName = "$baseName ($counter)" + } + + while (uniqueName in usedNames) { + uniqueName = "$baseName ($counter)" + counter++ + } + + usedNames.add(uniqueName) + result.add(tun.copy(tunName = uniqueName)) + } + return result +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt index 29403498..e5e56933 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt @@ -26,15 +26,17 @@ fun String.isValidIpv4orIpv6Address(): Boolean { return ipv4Pattern.matches(sanitized) || ipv6Pattern.matches(sanitized) } -fun String.isValidProxyBindAddress(): Boolean { +// only allow valid Android ports +fun String.isValidAndroidProxyBindAddress(): Boolean { + // Regex: IPv4 address with mandatory port (1–65535) val regex = Regex( - """^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(:\d{1,5})?$""" + """^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d):([1-9]\d{0,4}|[1-5]\d{4}|6[0-5][0-5][0-3][0-5])$""" ) if (!regex.matches(this)) return false - val port = this.substringAfter(":", "").toIntOrNull() ?: return false - return port in 1..65535 + val port = this.substringAfter(":").toIntOrNull() ?: return false + return port in 1024..65535 } fun String.hasNumberInParentheses(): Boolean { @@ -47,7 +49,7 @@ fun String.extractNameAndNumber(): Pair? { return matchResult?.let { Pair(it.groupValues[1], it.groupValues[2].toInt()) } } -fun List.isMatchingToWildcardList(value: String): Boolean { +fun Set.isMatchingToWildcardList(value: String): Boolean { val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").transformWildcardsToRegex() } Timber.d("Excluded values: $excludeValues") @@ -87,3 +89,10 @@ fun Iterable.joinAndTrim(): String { fun String.toTrimmedList(): List { return this.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + +inline fun String?.ifNotBlank(block: (String) -> Unit): String? { + if (this != null && isNotBlank()) { + block(this) + } + return this +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt index e10110f0..1e2fdca9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/TunnelExtensions.kt @@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.util.extensions import androidx.compose.ui.graphics.Color import com.wireguard.android.backend.BackendException +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.enums.HandshakeStatus import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus @@ -11,8 +12,10 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.Straw import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.NumberUtils +import com.zaneschepke.wireguardautotunnel.util.StringValue import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Tunnel +import org.amnezia.awg.config.BadConfigException import org.amnezia.awg.config.Config import timber.log.Timber @@ -38,6 +41,42 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus { } } +fun BadConfigException.asStringValue(): StringValue { + val reason = + when (this.reason) { + BadConfigException.Reason.INVALID_KEY -> R.string.invalid_key + BadConfigException.Reason.INVALID_NUMBER -> R.string.invalid_number + BadConfigException.Reason.INVALID_VALUE -> R.string.invalid_value + BadConfigException.Reason.MISSING_ATTRIBUTE -> R.string.missing_attribute + BadConfigException.Reason.MISSING_SECTION -> R.string.missing_section + BadConfigException.Reason.SYNTAX_ERROR -> R.string.syntax_error + BadConfigException.Reason.UNKNOWN_ATTRIBUTE -> R.string.unknown_attribute + BadConfigException.Reason.UNKNOWN_SECTION -> R.string.unknown_section + } + val location = this.location.name + return StringValue.StringResource(R.string.config_error_template, reason, location) +} + +fun com.wireguard.config.BadConfigException.asStringValue(): StringValue { + val reason = + when (this.reason) { + com.wireguard.config.BadConfigException.Reason.INVALID_KEY -> R.string.invalid_key + com.wireguard.config.BadConfigException.Reason.INVALID_NUMBER -> R.string.invalid_number + com.wireguard.config.BadConfigException.Reason.INVALID_VALUE -> R.string.invalid_value + com.wireguard.config.BadConfigException.Reason.MISSING_ATTRIBUTE -> + R.string.missing_attribute + com.wireguard.config.BadConfigException.Reason.MISSING_SECTION -> + R.string.missing_section + com.wireguard.config.BadConfigException.Reason.SYNTAX_ERROR -> R.string.syntax_error + com.wireguard.config.BadConfigException.Reason.UNKNOWN_ATTRIBUTE -> + R.string.unknown_attribute + com.wireguard.config.BadConfigException.Reason.UNKNOWN_SECTION -> + R.string.unknown_section + } + val location = this.location.name + return StringValue.StringResource(R.string.config_error_template, reason, location) +} + fun TunnelStatistics?.asColor(): Color { return this?.mapPeerStats() ?.map { it.value?.handshakeStatus() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt index f2d83641..511527f8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/UiExtensions.kt @@ -1,47 +1,66 @@ package com.zaneschepke.wireguardautotunnel.util.extensions +import android.annotation.SuppressLint import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute +import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.ui.navigation.Route +import kotlin.reflect.KClass -fun NavController.goFromRoot(route: Route) { - if (currentBackStackEntry?.isCurrentRoute(route::class) == true) return - this.navigate(route) { - popUpTo(Route.Main) { saveState = true } - launchSingleTop = true +@SuppressLint("RestrictedApi") +fun NavBackStackEntry?.isCurrentRoute(cls: KClass): Boolean { + return this?.destination?.hierarchy?.any { it.hasRoute(route = cls) } == true +} + +@Composable +inline fun NavBackStackEntry.sharedViewModel( + navController: NavController +): T { + val navGraphRoute = destination.parent?.route ?: return hiltViewModel() + val parentEntry = remember(this) { navController.getBackStackEntry(navGraphRoute) } + return hiltViewModel(parentEntry) +} + +fun WifiDetectionMethod.asTitleString(context: Context): String { + return when (this) { + WifiDetectionMethod.DEFAULT -> context.getString(R.string._default) + WifiDetectionMethod.LEGACY -> context.getString(R.string.legacy) + WifiDetectionMethod.ROOT -> context.getString(R.string.root) + WifiDetectionMethod.SHIZUKU -> context.getString(R.string.shizuku) } } -fun AndroidNetworkMonitor.WifiDetectionMethod.asTitleString(context: Context): String { +fun WifiDetectionMethod.to(): AndroidNetworkMonitor.WifiDetectionMethod { return when (this) { - AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT -> context.getString(R.string._default) - AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> context.getString(R.string.legacy) - AndroidNetworkMonitor.WifiDetectionMethod.ROOT -> context.getString(R.string.root) - AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU -> context.getString(R.string.shizuku) + WifiDetectionMethod.DEFAULT -> AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT + WifiDetectionMethod.LEGACY -> AndroidNetworkMonitor.WifiDetectionMethod.LEGACY + WifiDetectionMethod.ROOT -> AndroidNetworkMonitor.WifiDetectionMethod.ROOT + WifiDetectionMethod.SHIZUKU -> AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU } } -fun AndroidNetworkMonitor.WifiDetectionMethod.asDescriptionString(context: Context): String? { +fun WifiDetectionMethod.asDescriptionString(context: Context): String? { return when (this) { - AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> - context.getString(R.string.legacy_api_description) - AndroidNetworkMonitor.WifiDetectionMethod.ROOT -> - context.getString(R.string.use_root_shell_for_wifi) - AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU -> - context.getString(R.string.use_shell_via_shizuku) - AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT -> - context.getString(R.string.use_android_recommended) + WifiDetectionMethod.LEGACY -> context.getString(R.string.legacy_api_description) + WifiDetectionMethod.ROOT -> context.getString(R.string.use_root_shell_for_wifi) + WifiDetectionMethod.SHIZUKU -> context.getString(R.string.use_shell_via_shizuku) + WifiDetectionMethod.DEFAULT -> context.getString(R.string.use_android_recommended) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt deleted file mode 100644 index 7554370c..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt +++ /dev/null @@ -1,925 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.viewmodel - -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.net.Uri -import android.os.Build -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.wireguard.android.backend.WgQuickBackend -import com.wireguard.android.util.RootShell -import com.zaneschepke.logcatter.LogReader -import com.zaneschepke.logcatter.model.LogMessage -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor -import com.zaneschepke.networkmonitor.NetworkMonitor -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider -import com.zaneschepke.wireguardautotunnel.di.AppShell -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.di.MainDispatcher -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType -import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException -import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings -import com.zaneschepke.wireguardautotunnel.domain.model.AppState -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository -import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.ui.theme.Theme -import com.zaneschepke.wireguardautotunnel.util.* -import com.zaneschepke.wireguardautotunnel.util.extensions.addAllUnique -import com.zaneschepke.wireguardautotunnel.util.extensions.withFirstState -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent -import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent -import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.IOException -import java.net.URL -import java.time.Instant -import java.util.* -import javax.inject.Inject -import javax.inject.Provider -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.amnezia.awg.config.BadConfigException -import org.amnezia.awg.config.Config -import rikka.shizuku.Shizuku -import timber.log.Timber -import xyz.teamgravity.pin_lock_compose.PinManager - -@HiltViewModel -class AppViewModel -@Inject -constructor( - val appDataRepository: AppDataRepository, - val proxySettingsRepository: ProxySettingsRepository, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - @MainDispatcher private val mainDispatcher: CoroutineDispatcher, - @AppShell private val rootShell: Provider, - val tunnelManager: TunnelManager, - private val serviceManager: ServiceManager, - private val logReader: LogReader, - private val fileUtils: FileUtils, - private val shortcutManager: ShortcutManager, - networkMonitor: NetworkMonitor, -) : ViewModel() { - - private var logsJob: Job? = null - - private val _eventChannel = Channel(Channel.BUFFERED) - - private val tunnelMutex = Mutex() - private val settingsMutex = Mutex() - private val tunControlMutex = Mutex() - private val loggerMutex = Mutex() - - private val _screenCallback = MutableStateFlow<(() -> Unit)?>(null) - - private val _appViewState = MutableStateFlow(AppViewState()) - val appViewState: StateFlow = _appViewState.asStateFlow() - - private val _uiEvent = MutableSharedFlow() - val uiEvent: SharedFlow = _uiEvent.asSharedFlow() - - private val _logs = MutableStateFlow>(emptyList()) - val logs: StateFlow> = _logs.asStateFlow() - private val maxLogSize = Constants.MAX_LOG_SIZE - - val uiState: StateFlow = - combine( - combine( - appDataRepository.settings.flow, - appDataRepository.tunnels.flow, - appDataRepository.appState.flow, - ) { settings, tunnels, appState -> - Triple(settings, tunnels, appState) - }, - combine( - tunnelManager.activeTunnels, - serviceManager.autoTunnelService.map { it != null }, - proxySettingsRepository.flow, - ) { activeTunnels, autoTunnel, proxySettings -> - Triple(activeTunnels, autoTunnel, proxySettings) - }, - networkMonitor.connectivityStateFlow, - ) { repoTriple, triple, network -> - val (settings, tunnels, appState) = repoTriple - val (activeTunnels, autoTunnel, proxySettings) = triple - - AppUiState( - appSettings = settings, - proxySettings = proxySettings, - tunnels = tunnels, - activeTunnels = activeTunnels, - appState = appState, - isAutoTunnelActive = autoTunnel, - isAppLoaded = true, - connectivityState = network, - ) - } - .stateIn( - viewModelScope + ioDispatcher, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - AppUiState(), - ) - - init { - viewModelScope.launch(ioDispatcher) { - uiState.withFirstState { state -> - initPin(state.appState.isPinLockEnabled) - if (state.appState.isLocalLogsEnabled) logsJob = startCollectingLogs() - handleTunnelMessages() - } - for (event in _eventChannel) { - val state = uiState.value - when (event) { - AppEvent.ToggleLocalLogging -> - handleToggleLocalLogging(state.appState.isLocalLogsEnabled) - - is AppEvent.SetDebounceDelay -> - handleSetDebounceDelay(state.appSettings, event.delay) - - is AppEvent.CopySelectedTunnel -> handleCopySelectedTunnel(state.tunnels) - is AppEvent.DeleteSelectedTunnels -> handleDeleteSelectedTunnels() - is AppEvent.ImportTunnelFromClipboard -> - handleClipboardImport(event.text, state.tunnels) - - is AppEvent.ImportTunnelFromFile -> handleImportTunnelFromFile(event.data) - - is AppEvent.ImportTunnelFromUrl -> - handleImportTunnelFromUrl(event.url, state.tunnels) - - is AppEvent.ImportTunnelFromQrCode -> - handleImportTunnelFromQr(event.qrCode, state.tunnels) - - AppEvent.SetBatteryOptimizeDisableShown -> setBatteryOptimizeDisableShown() - is AppEvent.StartTunnel -> handleStartTunnel(event.tunnel, state.appSettings) - is AppEvent.StopTunnel -> handleStopTunnel(event.tunnel) - AppEvent.ToggleAutoTunnel -> handleToggleAutoTunnel(state) - is AppEvent.ToggleTunnelStatsExpanded -> - handleToggleTunnelStats(event.tunnelId, state.appState) - - AppEvent.ToggleAlwaysOn -> handleToggleAlwaysOnVPN(state.appSettings) - AppEvent.TogglePinLock -> handlePinLockToggled(state.appState.isPinLockEnabled) - AppEvent.SetLocationDisclosureShown -> setLocationDisclosureShown() - is AppEvent.SetLocale -> handleLocaleChange(event.localeTag) - AppEvent.ToggleRestartAtBoot -> handleToggleRestartAtBoot(state.appSettings) - AppEvent.ToggleVpnKillSwitch -> handleToggleVpnKillSwitch(state.appSettings) - AppEvent.ToggleLanOnKillSwitch -> handleToggleLanOnKillSwitch(state.appSettings) - AppEvent.ToggleAppShortcuts -> handleToggleAppShortcuts(state.appSettings) - is AppEvent.SetTheme -> handleThemeChange(event.theme) - is AppEvent.ToggleIpv4Preferred -> handleToggleIpv4(event.tunnel) - is AppEvent.TogglePrimaryTunnel -> handleTogglePrimaryTunnel(event.tunnel) - is AppEvent.AddTunnelRunSSID -> - handleAddTunnelRunSSID(event.ssid, event.tunnel, state.tunnels) - - is AppEvent.DeleteTunnelRunSSID -> - handleRemoveTunnelRunSSID(event.ssid, event.tunnel) - - is AppEvent.ToggleEthernetTunnel -> handleToggleEthernetTunnel(event.tunnel) - is AppEvent.ToggleMobileDataTunnel -> handleToggleMobileDataTunnel(event.tunnel) - AppEvent.ToggleAutoTunnelOnCellular -> - handleToggleAutoTunnelOnCellular(state.appSettings) - - AppEvent.ToggleAutoTunnelOnWifi -> - handleToggleAutoTunnelOnWifi(state.appSettings) - - is AppEvent.DeleteTrustedSSID -> - handleDeleteTrustedSSID(event.ssid, state.appSettings) - - AppEvent.ToggleAutoTunnelWildcards -> - handleToggleAutoTunnelWildcards(state.appSettings) - - is AppEvent.SaveTrustedSSID -> - handleSaveTrustedSSID(event.ssid, state.appSettings) - - AppEvent.ToggleAutoTunnelOnEthernet -> - handleToggleTunnelOnEthernet(state.appSettings) - - AppEvent.ToggleStopKillSwitchOnTrusted -> - handleToggleStopKillSwitchOnTrusted(state.appSettings) - - AppEvent.ToggleStopTunnelOnNoInternet -> - handleToggleStopOnNoInternet(state.appSettings) - - is AppEvent.ExportSelectedTunnels -> - handleExportSelectedTunnels(event.configType, event.uri) - - AppEvent.ExportLogs -> handleExportLogs() - AppEvent.MessageShown -> handleErrorShown() - is AppEvent.ToggleRestartOnPingFailure -> handleTogglePingTunnel(event.tunnel) - is AppEvent.SetTunnelPingTarget -> - handleTunnelPingTargetChange(event.tunnelConf, event.host) - - is AppEvent.SetBottomSheet -> handleSetBottomSheet(event.showSheet) - AppEvent.DeleteLogs -> handleDeleteLogs() - is AppEvent.SetScreenAction -> _screenCallback.update { event.callback } - AppEvent.InvokeScreenAction -> _screenCallback.value?.invoke() - is AppEvent.ToggleSelectedTunnel -> handleToggleSelectedTunnel(event.tunnel) - is AppEvent.ToggleSelectAllTunnels -> - handleToggleSelectAllTunnels(state.tunnels) - - AppEvent.VpnPermissionRequested -> requestVpnPermission(false) - is AppEvent.AppReadyCheck -> handleAppReadyCheck(event.tunnels) - is AppEvent.ShowMessage -> handleShowMessage(event.message) - is AppEvent.PopBackStack -> - _appViewState.update { it.copy(popBackStack = event.pop) } - - AppEvent.ToggleRemoteControl -> handleToggleRemoteControl(state.appState) - AppEvent.ClearSelectedTunnels -> clearSelectedTunnels() - is AppEvent.SetShowModal -> - _appViewState.update { it.copy(showModal = event.modalType) } - - is AppEvent.SetDetectionMethod -> - handleSetDetectionMethod(event.detectionMethod, state.appSettings) - - is AppEvent.SaveAllConfigs -> saveAllTunnels(event.tunnels) - AppEvent.ToggleShowDetailedPingStats -> - handleToggleShowDetailedPingStats(state.appState) - - is AppEvent.SaveMonitoringSettings -> - handleMonitoringSaveChanges( - state.appSettings, - event.pingInterval, - event.tunnelPingAttempts, - event.pingTimeout, - ) - - AppEvent.TogglePingMonitoring -> handleTogglePingMonitoring(state.appSettings) - is AppEvent.SetPingAttempts -> - saveSettings(state.appSettings.copy(tunnelPingAttempts = event.count)) - - is AppEvent.SetPingInterval -> - saveSettings( - state.appSettings.copy(tunnelPingIntervalSeconds = event.interval) - ) - - is AppEvent.SetPingTimeout -> - saveSettings( - state.appSettings.copy(tunnelPingTimeoutSeconds = event.timeout) - ) - is AppEvent.SaveTunnel -> saveTunnel(event.tunnel) - is AppEvent.SetAppMode -> handleSetAppMode(event.appMode, state.appSettings) - is AppEvent.SetProxySettings -> - proxySettingsRepository.save( - state.proxySettings.copy( - proxyUsername = event.username.ifBlank { null }, - proxyPassword = event.password.ifBlank { null }, - socks5ProxyEnabled = event.socks5Enabled, - httpProxyEnabled = event.httpProxyEnabled, - socks5ProxyBindAddress = event.socks5BindAddress?.ifBlank { null }, - httpProxyBindAddress = event.httpBindAddress?.ifBlank { null }, - ) - ) - - is AppEvent.SetDnsProtocol -> - handleSetDnsProtocol(event.dnsProtocol, state.appSettings) - is AppEvent.SetDnsProvider -> - handleSetDnsProvider(event.dnsProvider, state.appSettings) - } - } - } - } - - private suspend fun handleSetDnsProvider(dnsProvider: DnsProvider, appSettings: AppSettings) { - saveSettings(appSettings.copy(dnsEndpoint = dnsProvider.asAddress(appSettings.dnsProtocol))) - } - - private suspend fun handleSetDnsProtocol(protocol: DnsProtocol, appSettings: AppSettings) { - saveSettings(appSettings.copy(dnsProtocol = protocol)) - } - - private suspend fun handleSetAppMode(appMode: AppMode, appSettings: AppSettings) { - when (appMode) { - AppMode.VPN, - AppMode.PROXY -> Unit - AppMode.LOCK_DOWN -> { - if (!tunnelManager.hasVpnPermission()) return requestVpnPermission(true) - } - AppMode.KERNEL -> { - if (!isKernelSupported()) - return handleShowMessage( - StringValue.StringResource(R.string.kernel_not_supported) - ) - if (!requestRoot()) return - } - } - Timber.d("Saving new app mode ${appMode.name}") - saveSettings(appSettings.copy(appMode = appMode)) - } - - private suspend fun saveTunnelsUniquely(tunnels: List) { - withContext(ioDispatcher) { - tunnelMutex.withLock { - val existingTunnels = appDataRepository.tunnels.getAll() - val uniqueTuns = generateUniquelyNamedConfigs(tunnels, existingTunnels) - appDataRepository.tunnels.saveAll(uniqueTuns) - } - } - } - - private fun generateUniquelyNamedConfigs( - incoming: List, - existing: List, - ): List { - val usedNames = existing.map { it.tunName }.toMutableSet() - val result = mutableListOf() - - for (tun in incoming) { - var uniqueName = tun.tunName - var counter = 1 - - while (uniqueName in usedNames) { - uniqueName = "${tun.tunName} ($counter)" - counter++ - } - - usedNames.add(uniqueName) - result.add(tun.copy(tunName = uniqueName)) - } - - return result - } - - fun handleUiEvent(event: UiEvent): Job = - viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) } - - fun handleEvent(event: AppEvent) { - _eventChannel.trySend(event) - } - - private suspend fun handleTogglePingMonitoring(appSettings: AppSettings) { - saveSettings(appSettings.copy(isPingEnabled = !appSettings.isPingEnabled)) - } - - private suspend fun handleMonitoringSaveChanges( - appSettings: AppSettings, - pingInterval: Int, - tunnelPingAttempts: Int, - pingTimeout: Int?, - ) { - saveSettings( - appSettings.copy( - tunnelPingIntervalSeconds = pingInterval, - tunnelPingAttempts = tunnelPingAttempts, - tunnelPingTimeoutSeconds = pingTimeout, - ) - ) - } - - private suspend fun handleToggleShowDetailedPingStats(currentAppState: AppState) { - appDataRepository.appState.setShowDetailedPingStats(!currentAppState.showDetailedPingStats) - } - - private suspend fun saveAllTunnels(tunnels: List) { - appDataRepository.tunnels.saveAll(tunnels) - } - - private suspend fun handleSetDetectionMethod( - detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod, - appSettings: AppSettings, - ) { - if (detectionMethod == appSettings.wifiDetectionMethod) return - when (detectionMethod) { - AndroidNetworkMonitor.WifiDetectionMethod.ROOT -> if (!requestRoot()) return - AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU -> { - Shizuku.addRequestPermissionResultListener( - Shizuku.OnRequestPermissionResultListener { requestCode: Int, grantResult: Int - -> - if (grantResult != PERMISSION_GRANTED) - return@OnRequestPermissionResultListener - viewModelScope.launch { - saveSettings(appSettings.copy(wifiDetectionMethod = detectionMethod)) - } - } - ) - try { - if (Shizuku.checkSelfPermission() != PERMISSION_GRANTED) - return Shizuku.requestPermission(123) - } catch (e: Exception) { - Timber.e(e) - return handleShowMessage( - StringValue.StringResource(R.string.shizuku_not_detected) - ) - } - } - else -> Unit - } - saveSettings(appSettings.copy(wifiDetectionMethod = detectionMethod)) - } - - private fun handleToggleSelectAllTunnels(tunnels: List) = - _appViewState.update { it -> - val remove = tunnels.size == it.selectedTunnels.size - it.copy( - selectedTunnels = - it.selectedTunnels.toMutableList().apply { - if (remove) removeAll(tunnels) - else addAllUnique(tunnels) { existing, new -> existing.id == new.id } - } - ) - } - - private fun handleToggleSelectedTunnel(tunnel: TunnelConf) = - _appViewState.update { - it.copy( - selectedTunnels = - it.selectedTunnels.toMutableList().apply { - if (it.selectedTunnels.contains(tunnel)) remove(tunnel) else add(tunnel) - } - ) - } - - private suspend fun handleToggleTunnelStats(tunnelId: Int, appState: AppState) { - if (appState.expandedTunnelIds.contains(tunnelId)) { - appDataRepository.appState.removeTunnelExpanded(tunnelId) - } else { - appDataRepository.appState.setTunnelExpanded(tunnelId) - } - } - - private suspend fun handleToggleRemoteControl(appState: AppState) { - val enabled = !appState.isRemoteControlEnabled - if (enabled) appDataRepository.appState.setRemoteKey(UUID.randomUUID().toString()) - appDataRepository.appState.setIsRemoteControlEnabled(enabled) - } - - private fun startCollectingLogs() = - viewModelScope.launch { - logReader.bufferedLogs.flowOn(ioDispatcher).collect { logMessage -> - _logs.update { currentList -> - val newList = currentList.toMutableList() - if (newList.size >= maxLogSize) { - newList.removeAt(0) - } - newList.add(logMessage) - newList - } - } - } - - private fun handleTunnelMessages() = - viewModelScope.launch { - launch { - tunnelManager.errorEvents.collect { errorEvent -> - handleShowMessage( - when (val event = errorEvent.second) { - is BackendCoreException.BounceFailed -> event.toStringValue() - else -> - StringValue.StringResource( - R.string.tunnel_error_template, - errorEvent.second.toStringRes(), - ) - } - ) - } - } - launch { - tunnelManager.messageEvents.collect { messageEvent -> - handleShowMessage(messageEvent.second.toStringValue()) - } - } - } - - private suspend fun handleAppReadyCheck(tunnels: List) { - if (tunnels.size == appDataRepository.tunnels.count()) { - _appViewState.update { it.copy(isAppReady = true) } - } - } - - private fun handleSetBottomSheet(bottomSheet: AppViewState.BottomSheet) = - _appViewState.update { it.copy(bottomSheet = bottomSheet) } - - private suspend fun handleTunnelPingTargetChange(tunnelConf: TunnelConf, target: String) = - saveTunnel(tunnelConf.copy(pingTarget = target.ifBlank { null })) - - private suspend fun handleTogglePingTunnel(tunnel: TunnelConf) = - saveTunnel(tunnel.copy(restartOnPingFailure = !tunnel.restartOnPingFailure)) - - private suspend fun handleToggleLocalLogging(currentlyEnabled: Boolean) { - loggerMutex.withLock { - val enable = !currentlyEnabled - appDataRepository.appState.setLocalLogsEnabled(enable) - if (enable) { - logsJob?.cancel() - logReader.start() - logsJob = startCollectingLogs() - } else { - logReader.stop() - logsJob?.cancel() - _logs.update { emptyList() } - } - } - } - - private suspend fun handleSetDebounceDelay(appSettings: AppSettings, delay: Int) = - saveSettings(appSettings.copy(debounceDelaySeconds = delay)) - - private suspend fun handleCopySelectedTunnel(existingTunnels: List) { - val tunnel = _appViewState.value.selectedTunnels.firstOrNull() ?: return - saveTunnel( - TunnelConf( - tunName = tunnel.generateUniqueName(existingTunnels.map { it.tunName }), - wgQuick = tunnel.wgQuick, - amQuick = tunnel.amQuick, - ) - ) - clearSelectedTunnels() - } - - private fun clearSelectedTunnels() = - _appViewState.update { - it.copy(selectedTunnels = it.selectedTunnels.toMutableList().apply { clear() }) - } - - private suspend fun handleDeleteSelectedTunnels() = - _appViewState.value.selectedTunnels - .forEach { - appDataRepository.tunnels.delete(it) - appDataRepository.appState.removeTunnelExpanded(it.id) - } - .also { clearSelectedTunnels() } - - private fun requestVpnPermission(request: Boolean) = - _appViewState.update { it.copy(requestVpnPermission = request) } - - private fun requestBatteryPermission(request: Boolean) = - _appViewState.update { it.copy(requestBatteryPermission = request) } - - private suspend fun handleStartTunnel(tunnel: TunnelConf, appSettings: AppSettings) { - clearSelectedTunnels() - tunControlMutex.withLock { - if (!tunnelManager.hasVpnPermission() && appSettings.appMode == AppMode.VPN) - return@withLock requestVpnPermission(true) - tunnelManager.startTunnel(tunnel) - } - } - - private suspend fun handleStopTunnel(tunnel: TunnelConf) { - clearSelectedTunnels() - tunControlMutex.withLock { tunnelManager.stopTunnel(tunnel) } - } - - private suspend fun handleToggleAutoTunnel(state: AppUiState) { - tunControlMutex.withLock { - if ( - !state.appSettings.isAutoTunnelEnabled && - !tunnelManager.hasVpnPermission() && - state.appSettings.appMode == AppMode.VPN - ) { - return@withLock requestVpnPermission(true) - } - if (!state.appState.isBatteryOptimizationDisableShown) - return@withLock requestBatteryPermission(true) - serviceManager.toggleAutoTunnel() - } - } - - private fun handleErrorShown() { - _appViewState.update { it.copy(errorMessage = null) } - } - - private fun handleShowMessage(message: StringValue) { - _appViewState.update { it.copy(errorMessage = message) } - } - - private fun popBackStack() { - _appViewState.update { it.copy(popBackStack = true) } - } - - private suspend fun handleImportTunnelFromFile(uri: Uri) { - runCatching { - val tunnelConfigs = fileUtils.buildTunnelsFromUri(uri) - saveTunnelsUniquely(tunnelConfigs) - } - .onFailure { - when (it) { - is FileReadException, - is BadConfigException -> - handleShowMessage(StringValue.StringResource(R.string.error_file_format)) - is InvalidFileExtensionException -> - handleShowMessage(StringValue.StringResource(R.string.error_file_extension)) - else -> handleShowMessage(StringValue.StringResource(R.string.unknown_error)) - } - Timber.e(it) - } - } - - private suspend fun handleClipboardImport(config: String, tunnels: List) { - runCatching { - val amConfig = TunnelConf.configFromAmQuick(config) - val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig) - saveTunnelsUniquely(listOf(tunnelConf)) - } - .onFailure { - Timber.e(it) - handleShowMessage(StringValue.StringResource(R.string.error_file_format)) - } - } - - private suspend fun handleImportTunnelFromUrl(urlString: String, tunnels: List) { - runCatching { - val url = URL(urlString) - val fileName = urlString.substringAfterLast("/") - if (!fileName.endsWith(Constants.CONF_FILE_EXTENSION)) { - throw InvalidFileExtensionException - } - url.openStream().use { stream -> - val amConfig = Config.parse(stream) - val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig) - saveTunnelsUniquely(listOf(tunnelConf)) - } - } - .onFailure { - Timber.e(it) - val message = - when (it) { - is InvalidFileExtensionException -> - StringValue.StringResource(R.string.error_file_extension) - else -> StringValue.StringResource(R.string.error_download_failed) - } - handleShowMessage(message) - } - } - - private suspend fun handleImportTunnelFromQr( - result: String, - existingTunnels: List, - ) { - handleClipboardImport(result, existingTunnels) - popBackStack() - } - - private suspend fun setBatteryOptimizeDisableShown() { - requestBatteryPermission(false) - appDataRepository.appState.setBatteryOptimizationDisableShown(true) - } - - private fun initPin(enabled: Boolean) { - if (enabled) PinManager.initialize(WireGuardAutoTunnel.instance) - } - - private suspend fun handlePinLockToggled(currentlyEnabled: Boolean) { - if (currentlyEnabled) PinManager.clearPin() - appDataRepository.appState.setPinLockEnabled(!currentlyEnabled) - } - - private suspend fun setLocationDisclosureShown() { - appDataRepository.appState.setLocationDisclosureShown(true) - } - - private suspend fun handleToggleAlwaysOnVPN(appSettings: AppSettings) = - saveSettings(appSettings.copy(isAlwaysOnVpnEnabled = !appSettings.isAlwaysOnVpnEnabled)) - - private suspend fun handleLocaleChange(localeTag: String) { - withContext(mainDispatcher) { LocaleUtil.changeLocale(localeTag) } - appDataRepository.appState.setLocale(localeTag) - _appViewState.update { it.copy(isConfigChanged = true) } - } - - private suspend fun handleToggleRestartAtBoot(appSettings: AppSettings) = - saveSettings(appSettings.copy(isRestoreOnBootEnabled = !appSettings.isRestoreOnBootEnabled)) - - private suspend fun handleToggleVpnKillSwitch(appSettings: AppSettings) { - val enabled = !appSettings.isVpnKillSwitchEnabled - if (enabled && !tunnelManager.hasVpnPermission()) return requestVpnPermission(true) - val updatedSettings = - appSettings.copy( - isVpnKillSwitchEnabled = enabled, - isLanOnKillSwitchEnabled = - if (enabled) appSettings.isLanOnKillSwitchEnabled else false, - ) - saveSettings(updatedSettings) - // handleKillSwitchChange(updatedSettings) - } - - private suspend fun handleToggleLanOnKillSwitch(appSettings: AppSettings) { - val updatedSettings = - appSettings.copy(isLanOnKillSwitchEnabled = !appSettings.isLanOnKillSwitchEnabled) - saveSettings(updatedSettings) - // handleKillSwitchChange(updatedSettings) - } - - private suspend fun handleToggleAppShortcuts(appSettings: AppSettings) { - val enabled = !appSettings.isShortcutsEnabled - if (enabled) shortcutManager.addShortcuts() else shortcutManager.removeShortcuts() - saveSettings(appSettings.copy(isShortcutsEnabled = enabled)) - } - - private suspend fun handleTogglePrimaryTunnel(tunnelConf: TunnelConf) { - tunnelMutex.withLock { - appDataRepository.tunnels.updatePrimaryTunnel( - when (tunnelConf.isPrimaryTunnel) { - true -> null - false -> tunnelConf - } - ) - } - } - - private suspend fun handleToggleIpv4(tunnelConf: TunnelConf) = - saveTunnel(tunnelConf.copy(isIpv4Preferred = !tunnelConf.isIpv4Preferred)) - - private suspend fun handleThemeChange(theme: Theme) { - appDataRepository.appState.setTheme(theme) - } - - private suspend fun handleRemoveTunnelRunSSID(ssid: String, tunnelConfig: TunnelConf) = - saveTunnel( - tunnelConfig.copy(tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList()) - ) - - private suspend fun handleAddTunnelRunSSID( - ssid: String, - tunnelConf: TunnelConf, - existingTunnels: List, - ) { - if (ssid.isBlank()) return - val trimmed = ssid.trim() - if (existingTunnels.any { it.tunnelNetworks.contains(trimmed) }) - return handleShowMessage(StringValue.StringResource(R.string.error_ssid_exists)) - saveTunnel( - tunnelConf.copy(tunnelNetworks = (tunnelConf.tunnelNetworks + ssid).toMutableList()) - ) - } - - private suspend fun handleToggleMobileDataTunnel(tunnelConf: TunnelConf) { - tunnelMutex.withLock { - if (tunnelConf.isMobileDataTunnel) - return appDataRepository.tunnels.updateMobileDataTunnel(null) - appDataRepository.tunnels.updateMobileDataTunnel(tunnelConf) - } - } - - private suspend fun handleToggleEthernetTunnel(tunnelConf: TunnelConf) { - tunnelMutex.withLock { - if (tunnelConf.isEthernetTunnel) - return appDataRepository.tunnels.updateEthernetTunnel(null) - appDataRepository.tunnels.updateEthernetTunnel(tunnelConf) - } - } - - private suspend fun handleToggleAutoTunnelOnWifi(appSettings: AppSettings) = - saveSettings(appSettings.copy(isTunnelOnWifiEnabled = !appSettings.isTunnelOnWifiEnabled)) - - private suspend fun handleToggleAutoTunnelOnCellular(appSettings: AppSettings) = - saveSettings( - appSettings.copy(isTunnelOnMobileDataEnabled = !appSettings.isTunnelOnMobileDataEnabled) - ) - - private suspend fun handleToggleAutoTunnelWildcards(appSettings: AppSettings) = - saveSettings(appSettings.copy(isWildcardsEnabled = !appSettings.isWildcardsEnabled)) - - private suspend fun handleDeleteTrustedSSID(ssid: String, appSettings: AppSettings) = - saveSettings( - appSettings.copy( - trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs - ssid).toMutableList() - ) - ) - - private suspend fun handleToggleTunnelOnEthernet(appSettings: AppSettings) = - saveSettings( - appSettings.copy(isTunnelOnEthernetEnabled = !appSettings.isTunnelOnEthernetEnabled) - ) - - private suspend fun handleSaveTrustedSSID(ssid: String, appSettings: AppSettings) { - if (ssid.isEmpty()) return - val trimmed = ssid.trim() - if (appSettings.trustedNetworkSSIDs.contains(trimmed)) - return handleShowMessage(StringValue.StringResource(R.string.error_ssid_exists)) - saveSettings( - appSettings.copy( - trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs + ssid).toMutableList() - ) - ) - } - - private suspend fun handleToggleStopOnNoInternet(appSettings: AppSettings) = - saveSettings( - appSettings.copy(isStopOnNoInternetEnabled = !appSettings.isStopOnNoInternetEnabled) - ) - - private suspend fun handleToggleStopKillSwitchOnTrusted(appSettings: AppSettings) = - saveSettings( - appSettings.copy( - isDisableKillSwitchOnTrustedEnabled = - !appSettings.isDisableKillSwitchOnTrustedEnabled - ) - ) - - private suspend fun isKernelSupported(): Boolean { - return withContext(ioDispatcher) { WgQuickBackend.hasKernelSupport() } - } - - private suspend fun saveSettings(appSettings: AppSettings) = - withContext(ioDispatcher) { - settingsMutex.withLock { appDataRepository.settings.save(appSettings) } - } - - private suspend fun saveTunnel(tunnel: TunnelConf) = - withContext(ioDispatcher) { - tunnelMutex.withLock { appDataRepository.tunnels.save(tunnel) } - } - - private suspend fun handleExportSelectedTunnels(configType: ConfigType, uri: Uri?) { - val tunnels = _appViewState.value.selectedTunnels - try { - if (tunnels.isEmpty()) return - val (files, shareFileName) = - when (configType) { - ConfigType.AM -> { - val amFiles = fileUtils.createAmFiles(tunnels) - if (amFiles.isEmpty()) { - throw IOException("No valid Amnezia config files created") - } - Pair(amFiles, "am-export_${Instant.now().epochSecond}.zip") - } - ConfigType.WG -> { - val wgFiles = fileUtils.createWgFiles(tunnels) - if (wgFiles.isEmpty()) { - throw IOException("No valid WireGuard config files created") - } - Pair(wgFiles, "wg-export_${Instant.now().epochSecond}.zip") - } - } - - val shareFile = fileUtils.createNewShareFile(shareFileName) - fileUtils.zipAll(shareFile, files) - if (!shareFile.exists() || shareFile.length() == 0L) { - throw IOException("Zip file is empty or not created: ${shareFile.path}") - } - - // fall back to save to downloads for older devices - if (uri != null) { - val copyResult = fileUtils.copyFileToUri(shareFile, uri) - copyResult.fold( - onSuccess = { - handleShowMessage(StringValue.StringResource(R.string.export_success)) - }, - onFailure = { error -> - Timber.w("User likely cancelled or file write failed: ${error.message}") - handleShowMessage(StringValue.StringResource(R.string.export_failed)) - }, - ) - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - fileUtils.saveToDownloadsWithMediaStore(shareFile, Constants.ZIP_FILE_MIME_TYPE) - handleShowMessage(StringValue.StringResource(R.string.export_success)) - } else throw IOException("File exporting not supported on this device") - } - } catch (e: Exception) { - Timber.e(e, "Export failed") - handleShowMessage(StringValue.StringResource(R.string.export_failed)) - } finally { - handleSetBottomSheet(AppViewState.BottomSheet.NONE) - clearSelectedTunnels() - } - } - - private suspend fun handleExportLogs() { - runCatching { - val file = - fileUtils.createNewShareFile( - "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip" - ) - logReader.zipLogFiles(file.absolutePath) - fileUtils.shareFile(file) - } - .onFailure { - Timber.e(it) - handleShowMessage(StringValue.StringResource(R.string.export_failed)) - } - } - - private suspend fun handleDeleteLogs() { - loggerMutex.withLock { - logsJob?.cancel() - _logs.update { emptyList() } - logReader.stop() - logReader.deleteAndClearLogs() - logReader.start() - logsJob = startCollectingLogs() - } - } - - private suspend fun requestRoot(): Boolean { - return withContext(ioDispatcher) { - try { - rootShell.get().start() - handleShowMessage(StringValue.StringResource(R.string.root_accepted)) - true - } catch (_: Exception) { - handleShowMessage(StringValue.StringResource(R.string.error_root_denied)) - false - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt new file mode 100644 index 00000000..a6b4f63a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt @@ -0,0 +1,125 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.AutoTunnelUiState +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class AutoTunnelViewModel +@Inject +constructor( + private val settingsRepository: GeneralSettingRepository, + private val serviceManager: ServiceManager, + private val networkMonitor: NetworkMonitor, + private val globalEffectRepository: GlobalEffectRepository, + private val appStateRepository: AppStateRepository, +) : ContainerHost, ViewModel() { + + override val container = + container( + AutoTunnelUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + intent { + combine( + networkMonitor.connectivityStateFlow, + serviceManager.autoTunnelService.map { it != null }, + settingsRepository.flow, + appStateRepository.flow, + ) { connectivity, active, settings, appState -> + AutoTunnelUiState( + autoTunnelActive = active, + connectivityState = connectivity, + generalSettings = settings, + isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown, + stateInitialized = true, + ) + } + .collect { reduce { it } } + } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun setLocationDisclosureShown() = intent { + appStateRepository.setLocationDisclosureShown(true) + } + + fun toggleAutoTunnel() = intent { + if (!state.autoTunnelActive) { + when (state.generalSettings.appMode) { + AppMode.VPN -> + if (!serviceManager.hasVpnPermission()) + return@intent postSideEffect( + GlobalSideEffect.RequestVpnPermission(AppMode.VPN, null) + ) + else -> Unit + } + if (!state.isBatteryOptimizationShown) { + return@intent postSideEffect(GlobalSideEffect.RequestBatteryOptimizationDisabled) + } + serviceManager.startAutoTunnel() + } else { + serviceManager.stopAutoTunnel() + } + } + + fun setAutoTunnelOnWifiEnabled(to: Boolean) = intent { + settingsRepository.save(state.generalSettings.copy(isTunnelOnWifiEnabled = to)) + } + + fun setWildcardsEnabled(to: Boolean) = intent { + settingsRepository.save(state.generalSettings.copy(isWildcardsEnabled = to)) + } + + fun setStopOnNoInternetEnabled(to: Boolean) = intent { + settingsRepository.save(state.generalSettings.copy(isStopOnNoInternetEnabled = to)) + } + + fun saveTrustedNetworkName(name: String) = intent { + if (name.isEmpty()) return@intent + val trimmed = name.trim() + if (state.generalSettings.trustedNetworkSSIDs.contains(name)) { + return@intent postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.error_ssid_exists)) + ) + } + setTrustedNetworkNames((state.generalSettings.trustedNetworkSSIDs + trimmed).toMutableSet()) + } + + fun setTrustedNetworkNames(to: Set) = intent { + settingsRepository.save(state.generalSettings.copy(trustedNetworkSSIDs = to)) + } + + fun removeTrustedNetworkName(name: String) = intent { + setTrustedNetworkNames((state.generalSettings.trustedNetworkSSIDs - name).toMutableSet()) + } + + fun setTunnelOnCellular(to: Boolean) = intent { + settingsRepository.save(state.generalSettings.copy(isTunnelOnMobileDataEnabled = to)) + } + + fun setTunnelOnEthernet(to: Boolean) = intent { + settingsRepository.save(state.generalSettings.copy(isTunnelOnMobileDataEnabled = to)) + } + + fun setDebounceDelay(to: Int) = intent { + settingsRepository.save(state.generalSettings.copy(debounceDelaySeconds = to)) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt new file mode 100644 index 00000000..408790eb --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/LoggerViewModel.kt @@ -0,0 +1,97 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.logcatter.LogReader +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.LoggerUiState +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.FileUtils +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber + +@HiltViewModel +class LoggerViewModel +@Inject +constructor( + private val logReader: LogReader, + private val appStateRepository: AppStateRepository, + private val fileUtils: FileUtils, + private val globalEffectRepository: GlobalEffectRepository, +) : ContainerHost, ViewModel() { + + @OptIn(ExperimentalCoroutinesApi::class) + override val container = + container( + LoggerUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + intent { + appStateRepository.flow + .map { it.isLocalLogsEnabled } + .distinctUntilChanged() + .onEach { enabled -> + if (enabled) { + logReader.start() + } else { + logReader.stop() + logReader.deleteAndClearLogs() + reduce { state.copy(messages = emptyList()) } + } + } + .flatMapLatest { enabled -> + if (enabled) logReader.bufferedLogs else emptyFlow() + } + .catch { e -> Timber.e(e) } + .collect { logMessage -> + reduce { + state.copy( + messages = + state.messages.toMutableList().apply { + if (size >= MAX_LOG_SIZE) removeAt(0) + add(logMessage) + } + ) + } + } + } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun exportLogs() = intent { + val result = + fileUtils.createNewShareFile( + "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip" + ) + result.onSuccess { file -> postSideEffect(GlobalSideEffect.ShareFile(file)) } + result.onFailure { error -> + val message = + error.message?.let { StringValue.DynamicString(it) } + ?: StringValue.StringResource(R.string.unknown_error) + postSideEffect(GlobalSideEffect.Toast(message)) + } + } + + fun deleteLogs() = intent { + appStateRepository.setLocalLogsEnabled(false) + delay(1_000L) + appStateRepository.setLocalLogsEnabled(true) + } + + companion object { + const val MAX_LOG_SIZE = 10_000L + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt new file mode 100644 index 00000000..22957511 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ProxySettingsViewModel.kt @@ -0,0 +1,102 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.AppProxySettings +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.ProxySettingsUiState +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.isValidAndroidProxyBindAddress +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class ProxySettingsViewModel +@Inject +constructor( + private val proxySettingsRepository: ProxySettingsRepository, + private val globalEffectRepository: GlobalEffectRepository, +) : ContainerHost, ViewModel() { + + override val container = + container( + ProxySettingsUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + proxySettingsRepository.flow.collect { + reduce { state.copy(proxySettings = it, stateInitialized = true) } + } + } + + fun save(proxySettings: AppProxySettings) = intent { + val updated = + proxySettings.copy( + httpProxyBindAddress = proxySettings.httpProxyBindAddress?.ifBlank { null }, + socks5ProxyBindAddress = proxySettings.socks5ProxyBindAddress?.ifBlank { null }, + proxyUsername = proxySettings.proxyUsername?.ifBlank { null }, + proxyPassword = proxySettings.proxyPassword?.ifBlank { null }, + ) + val isHttpDefault = updated.httpProxyBindAddress == null + val isSocks5Default = updated.socks5ProxyBindAddress == null + + // Validate bind addresses + if (!isSocks5Default && !updated.socks5ProxyBindAddress.isValidAndroidProxyBindAddress()) { + return@intent reduce { state.copy(isSocks5BindAddressError = true) } + } + if (!isHttpDefault && !updated.httpProxyBindAddress.isValidAndroidProxyBindAddress()) { + return@intent reduce { state.copy(isHttpBindAddressError = true) } + } + // Validate different ports + if (!isHttpDefault && !isSocks5Default) { + val socksPort = updated.socks5ProxyBindAddress.split(":").last().toIntOrNull() + val httpPort = updated.httpProxyBindAddress.split(":").last().toIntOrNull() + if (socksPort == null || httpPort == null || socksPort == httpPort) { + return@intent postSideEffect( + GlobalSideEffect.Snackbar( + StringValue.StringResource(R.string.ports_must_differ) + ) + ) + } + } + // Validate username and password (both null or both not null) + if (!areBothNullOrBothNotNull(updated.proxyUsername, updated.proxyPassword)) { + return@intent reduce { + state.copy( + isUserNameError = updated.proxyUsername == null, + isPasswordError = updated.proxyPassword == null, + ) + } + } + // Validate password for whitespace + if (updated.proxyPassword?.any { it.isWhitespace() } == true) { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.password_no_spaces)) + ) + return@intent reduce { state.copy(isPasswordError = true) } + } + // Save if all validations pass + proxySettingsRepository.save(updated) + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + private fun areBothNullOrBothNotNull(s1: String?, s2: String?) = (s1 == null) == (s2 == null) + + fun setEnableSocks5(to: Boolean) = intent { + reduce { state.copy(proxySettings = state.proxySettings.copy(socks5ProxyEnabled = to)) } + } + + fun setEnableHttp(to: Boolean) = intent { + reduce { state.copy(proxySettings = state.proxySettings.copy(httpProxyEnabled = to)) } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..73dee9d6 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SettingsViewModel.kt @@ -0,0 +1,107 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager +import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol +import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider +import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.SettingUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.* +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class SettingsViewModel +@Inject +constructor( + private val settingsRepository: GeneralSettingRepository, + private val shortcutManager: ShortcutManager, + private val appStateRepository: AppStateRepository, + private val globalEffectRepository: GlobalEffectRepository, +) : ContainerHost, ViewModel() { + + override val container = + container( + SettingUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + intent { + combine(settingsRepository.flow, appStateRepository.flow) { settings, appState -> + SettingUiState( + settings = settings, + isLocalLoggingEnabled = appState.isLocalLogsEnabled, + remoteKey = appState.remoteKey, + isRemoteEnabled = appState.isRemoteControlEnabled, + isPinLockEnabled = appState.isPinLockEnabled, + showDetailedPingStats = appState.showDetailedPingStats, + stateInitialized = true, + ) + } + .collect { reduce { it } } + } + } + + fun setPingEnabled(to: Boolean) = intent { + settingsRepository.save(state.settings.copy(isPingEnabled = to)) + } + + fun setShortcutsEnabled(to: Boolean) = intent { + if (to) shortcutManager.addShortcuts() else shortcutManager.removeShortcuts() + settingsRepository.save(state.settings.copy(isShortcutsEnabled = to)) + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun setAlwaysOnVpnEnabled(to: Boolean) = intent { + settingsRepository.save(state.settings.copy(isAlwaysOnVpnEnabled = to)) + } + + fun setRestoreOnBootEnabled(to: Boolean) = intent { + settingsRepository.save(state.settings.copy(isRestoreOnBootEnabled = to)) + } + + fun setLanKillSwitchEnabled(to: Boolean) = intent { + settingsRepository.save(state.settings.copy(isLanOnKillSwitchEnabled = to)) + } + + fun setTunnelPingIntervalSeconds(to: Int) = intent { + settingsRepository.save(state.settings.copy(tunnelPingIntervalSeconds = to)) + } + + fun setTunnelPingAttempts(to: Int) = intent { + settingsRepository.save(state.settings.copy(tunnelPingAttempts = to)) + } + + fun setTunnelPingTimeoutSeconds(to: Int?) = intent { + settingsRepository.save(state.settings.copy(tunnelPingTimeoutSeconds = to)) + } + + fun setDnsProtocol(to: DnsProtocol) = intent { + settingsRepository.save(state.settings.copy(dnsProtocol = to)) + } + + fun setDetailedPingStats(to: Boolean) = intent { + appStateRepository.setShowDetailedPingStats(to) + } + + fun setLocalLogging(to: Boolean) = intent { appStateRepository.setLocalLogsEnabled(to) } + + fun setRemoteEnabled(to: Boolean) = intent { + appStateRepository.setRemoteKey(UUID.randomUUID().toString()) + appStateRepository.setIsRemoteControlEnabled(to) + } + + fun setDnsProvider(dnsProvider: DnsProvider) = intent { + settingsRepository.save( + state.settings.copy(dnsEndpoint = dnsProvider.asAddress(state.settings.dnsProtocol)) + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt new file mode 100644 index 00000000..cbab062b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -0,0 +1,196 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.core.content.PermissionChecker.PERMISSION_GRANTED +import androidx.lifecycle.ViewModel +import com.wireguard.android.util.RootShell +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod +import com.zaneschepke.wireguardautotunnel.di.AppShell +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState +import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState +import com.zaneschepke.wireguardautotunnel.ui.theme.Theme +import com.zaneschepke.wireguardautotunnel.util.LocaleUtil +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import javax.inject.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import rikka.shizuku.Shizuku +import xyz.teamgravity.pin_lock_compose.PinManager + +@HiltViewModel +class SharedAppViewModel +@Inject +constructor( + private val appStateRepository: AppStateRepository, + private val serviceManager: ServiceManager, + private val tunnelManager: TunnelManager, + private val globalEffectRepository: GlobalEffectRepository, + private val settingsRepository: GeneralSettingRepository, + @AppShell private val rootShell: Provider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ContainerHost, ViewModel() { + + val globalSideEffect = globalEffectRepository.flow + + override val container = + container( + SharedAppUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + intent { + combine( + appStateRepository.flow, + serviceManager.autoTunnelService.map { it != null }, + settingsRepository.flow, + ) { appState, autoTunnelActive, settings -> + state.copy( + theme = appState.theme, + locale = appState.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE, + pinLockEnabled = appState.isPinLockEnabled, + isAutoTunnelActive = autoTunnelActive, + settings = settings, + isLocationDisclosureShown = appState.isLocationDisclosureShown, + isAppLoaded = true, + ) + } + .collect { newState -> reduce { newState } } + } + + intent { + tunnelManager.errorEvents.collect { (tunnel, message) -> + postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) + } + } + + intent { + // TODO could improve this, maybe merge with errors, improve messages + tunnelManager.messageEvents.collect { (_, message) -> + postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) + } + } + } + + fun startTunnel(tunnelConf: TunnelConf) = intent { + if (state.settings.appMode == AppMode.VPN) { + if (!tunnelManager.hasVpnPermission()) + return@intent postSideEffect( + GlobalSideEffect.RequestVpnPermission(AppMode.VPN, tunnelConf) + ) + } + tunnelManager.startTunnel(tunnelConf) + } + + fun setTheme(theme: Theme) = intent { appStateRepository.setTheme(theme) } + + fun setLocale(locale: String) = intent { + appStateRepository.setLocale(locale) + postSideEffect(GlobalSideEffect.ConfigChanged) + } + + fun setPinLockEnabled(enabled: Boolean) = intent { + if (!enabled) PinManager.clearPin() + appStateRepository.setPinLockEnabled(enabled) + } + + fun stopTunnel(tunnelConf: TunnelConf) = intent { tunnelManager.stopTunnel(tunnelConf) } + + fun setAppMode(appMode: AppMode) = intent { + when (appMode) { + AppMode.VPN, + AppMode.PROXY -> Unit + AppMode.LOCK_DOWN -> { + if (!serviceManager.hasVpnPermission()) { + return@intent postSideEffect( + GlobalSideEffect.RequestVpnPermission(appMode, null) + ) + } + } + AppMode.KERNEL -> if (!requestRoot()) return@intent + } + settingsRepository.save(state.settings.copy(appMode = appMode)) + } + + fun updateNavbarState(newState: NavbarState) = intent { + reduce { state.copy(navBarState = newState) } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun authenticated() = intent { reduce { state.copy(isAuthorized = true) } } + + suspend fun postGlobalSideEffect(sideEffect: GlobalSideEffect) { + globalEffectRepository.post(sideEffect) + } + + fun showSnackMessage(message: StringValue) = intent { + postGlobalSideEffect(GlobalSideEffect.Snackbar(message)) + } + + fun showToast(message: StringValue) = intent { postSideEffect(GlobalSideEffect.Toast(message)) } + + fun disableBatteryOptimizationsShown() = intent { + appStateRepository.setBatteryOptimizationDisableShown(true) + } + + fun setWifiDetectionMethod(method: WifiDetectionMethod) = intent { + when (method) { + WifiDetectionMethod.ROOT -> if (!requestRoot()) return@intent + WifiDetectionMethod.SHIZUKU -> return@intent requestShizuku() + else -> Unit + } + settingsRepository.save(state.settings.copy(wifiDetectionMethod = method)) + } + + private fun requestShizuku() { + Shizuku.addRequestPermissionResultListener( + Shizuku.OnRequestPermissionResultListener { requestCode: Int, grantResult: Int -> + if (grantResult != PERMISSION_GRANTED) return@OnRequestPermissionResultListener + setWifiDetectionMethod(WifiDetectionMethod.SHIZUKU) + } + ) + try { + if (Shizuku.checkSelfPermission() != PERMISSION_GRANTED) Shizuku.requestPermission(123) + } catch (_: Exception) { + intent { + postSideEffect( + GlobalSideEffect.Snackbar( + StringValue.StringResource(R.string.shizuku_not_detected) + ) + ) + } + } + } + + private suspend fun requestRoot(): Boolean = + withContext(ioDispatcher) { + val accepted = + try { + rootShell.get().start() + true + } catch (_: Exception) { + false + } + val message = + if (!accepted) StringValue.StringResource(R.string.error_root_denied) + else StringValue.StringResource(R.string.root_accepted) + intent { postSideEffect(GlobalSideEffect.Snackbar(message)) } + accepted + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt new file mode 100644 index 00000000..95788ae8 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt @@ -0,0 +1,85 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.InstalledPackageRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.state.SplitTunnelUiState +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class SplitTunnelViewModel +@Inject +constructor( + private val tunnelRepository: TunnelRepository, + private val packageRepository: InstalledPackageRepository, + private val globalEffectRepository: GlobalEffectRepository, +) : ContainerHost, ViewModel() { + + override val container = + container( + SplitTunnelUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + val packagesFlow = flow { + val packages = packageRepository.getInstalledPackages() + emit(packages) + } + + combine(packagesFlow, tunnelRepository.flow) { packages, tunnels -> + SplitTunnelUiState(packages, true, tunnels) + } + .collect { reduce { it } } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun saveSplitTunnelSelection(tunnelId: Int, splitConfig: Pair>) = + intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + val config = latestTunnel.toAmConfig() + val (option, pkgs) = splitConfig + val configProxy = ConfigProxy.from(config) + val interfaceProxy = InterfaceProxy.from(config.`interface`) + val (included, excluded) = + when (option) { + SplitOption.INCLUDE -> Pair(pkgs, emptySet()) + SplitOption.ALL -> Pair(emptySet(), emptySet()) + SplitOption.EXCLUDE -> Pair(emptySet(), pkgs) + } + val updatedInterface = + interfaceProxy.copy( + includedApplications = included, + excludedApplications = excluded, + ) + val updatedConfig = configProxy.copy(`interface` = updatedInterface) + val (wg, am) = updatedConfig.buildConfigs() + tunnelRepository.save( + latestTunnel.copy( + amQuick = am.toAwgQuickString(true, false), + wgQuick = wg.toWgQuickString(true), + ) + ) + postSideEffect( + GlobalSideEffect.Snackbar( + StringValue.StringResource(R.string.config_changes_saved) + ) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SupportViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SupportViewModel.kt new file mode 100644 index 00000000..5d0adbb7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SupportViewModel.kt @@ -0,0 +1,116 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.BuildConfig +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.di.MainDispatcher +import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.SupportUiState +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import javax.inject.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel +class SupportViewModel +@Inject +constructor( + private val updateRepository: Provider, + @MainDispatcher private val mainDispatcher: CoroutineDispatcher, + private val globalEffectRepository: GlobalEffectRepository, +) : ContainerHost, ViewModel() { + + override val container = container(SupportUiState()) + + fun checkForUpdate() = intent { + if (BuildConfig.DEBUG || BuildConfig.FLAVOR == Constants.GOOGLE_PLAY_FLAVOR) + return@intent postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.update_check_unsupported) + ) + ) + postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.checking_for_update)) + ) + reduce { state.copy(isLoading = true) } + updateRepository + .get() + .checkForUpdate(BuildConfig.VERSION_NAME) + .onSuccess { update -> + if (update == null) { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.latest_installed) + ) + ) + } else reduce { state.copy(appUpdate = update.sanitized()) } + } + .onFailure { + postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.update_check_failed)) + ) + } + reduce { state.copy(isLoading = false) } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + private fun AppUpdate?.sanitized(): AppUpdate? { + return this?.copy(releaseNotes = releaseNotes.substringBefore(CHANGELOG_START)) + } + + fun viewReleaseNotes() = intent { + val version = + if (BuildConfig.VERSION_NAME.contains("nightly")) { + "nightly" + } else { + state.appUpdate?.version?.removePrefix("v")?.trim() ?: "" + } + val url = "${Constants.BASE_RELEASE_URL}$version".trim() + postSideEffect(GlobalSideEffect.LaunchUrl(url)) + } + + fun dismissUpdate() = intent { reduce { state.copy(appUpdate = null) } } + + fun downloadAndInstall() = intent { + if ( + state.appUpdate == null || + state.appUpdate?.apkUrl == null || + state.appUpdate?.apkFileName == null + ) + return@intent + reduce { state.copy(isLoading = true) } + updateRepository + .get() + .downloadApk(state.appUpdate!!.apkUrl!!, state.appUpdate!!.apkFileName!!) { progress -> + handleProgress(progress) + } + .onSuccess { postSideEffect(GlobalSideEffect.InstallApk(it)) } + .onFailure { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.update_download_failed) + ) + ) + } + } + + private fun handleProgress(progress: Float) = intent { + withContext(mainDispatcher) { reduce { state.copy(downloadProgress = progress) } } + } + + companion object { + private const val CHANGELOG_START = + "SHA-256 fingerprint for the 4096-bit signing certificate:" + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt new file mode 100644 index 00000000..8d6b59da --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt @@ -0,0 +1,330 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import android.net.Uri +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher +import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState +import com.zaneschepke.wireguardautotunnel.util.FileUtils +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig +import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName +import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import java.io.IOException +import java.net.URL +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.withContext +import org.amnezia.awg.config.BadConfigException +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber + +@HiltViewModel +class TunnelsViewModel +@Inject +constructor( + private val tunnelRepository: TunnelRepository, + private val generalSettingRepository: GeneralSettingRepository, + private val appStateRepository: AppStateRepository, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val fileUtils: FileUtils, + private val globalEffectRepository: GlobalEffectRepository, + private val tunnelManager: TunnelManager, +) : ContainerHost, ViewModel() { + + override val container = + container( + TunnelsUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + combine( + tunnelRepository.flow, + generalSettingRepository.flow, + appStateRepository.flow, + tunnelManager.activeTunnels, + ) { tunnels, settings, appState, activeTunnels -> + TunnelsUiState( + tunnels = tunnels, + activeTunnels = activeTunnels, + appMode = settings.appMode, + isPingEnabled = settings.isPingEnabled, + isWildcardsEnabled = settings.isWildcardsEnabled, + showPingStats = appState.showDetailedPingStats, + stateInitialized = true, + ) + } + .collect { reduce { it } } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + fun exportSelectedTunnels(configType: ConfigType, uri: Uri?, selected: Set) = + intent { + val (files, shareFileName) = + when (configType) { + ConfigType.AM -> + Pair(createAmFiles(selected), "am-export_${Instant.now().epochSecond}.zip") + ConfigType.WG -> + Pair(createWgFiles(selected), "wg-export_${Instant.now().epochSecond}.zip") + } + val onFailure = { action: Throwable -> + intent { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource( + R.string.export_failed, + ": ${action.localizedMessage}", + ) + ) + ) + } + Unit + } + fileUtils + .createNewShareFile(shareFileName) + .onSuccess { + fileUtils.zipAll(it, files).onFailure(onFailure) + fileUtils.exportFile(it, uri, FileUtils.ZIP_FILE_MIME_TYPE).onFailure(onFailure) + } + .onFailure(onFailure) + } + + suspend fun createWgFiles(tunnels: Collection): List = + withContext(ioDispatcher) { + tunnels.mapNotNull { config -> + if (config.wgQuick.isNotBlank()) { + fileUtils.createFile(config.name, config.wgQuick) + } else null + } + } + + suspend fun createAmFiles(tunnels: Collection): List = + withContext(ioDispatcher) { + tunnels.mapNotNull { config -> + if ( + config.amQuick != TunnelConfig.AM_QUICK_DEFAULT && config.amQuick.isNotBlank() + ) { + fileUtils.createFile(config.name, config.amQuick) + } else null + } + } + + fun saveConfigProxy(tunnelId: Int?, configProxy: ConfigProxy, tunnelName: String) = intent { + if (state.tunnels.any { it.name == tunnelName && it.id != tunnelId }) + return@intent postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.tunnel_name_taken)) + ) + runCatching { + val (wg, am) = configProxy.buildConfigs() + val tunnelConf = + if (tunnelId == null) { + TunnelConf.tunnelConfFromQuick(am.toAwgQuickString(true, false), tunnelName) + } else { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + latestTunnel?.copy( + tunName = tunnelName, + amQuick = am.toAwgQuickString(true, false), + wgQuick = wg.toWgQuickString(true), + ) + } + if (tunnelConf != null) { + tunnelRepository.save(tunnelConf) + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.config_changes_saved) + ) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + } + .onFailure { + Timber.e(it) + val message = + when (it) { + is BadConfigException -> it.asStringValue() + is com.wireguard.config.BadConfigException -> it.asStringValue() + else -> StringValue.StringResource(R.string.unknown_error) + } + postSideEffect(GlobalSideEffect.Snackbar(message)) + } + } + + fun saveSortChanges(tunnels: List) = intent { + tunnelRepository.saveAll(tunnels.mapIndexed { index, conf -> conf.copy(position = index) }) + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + + fun importTunnelConfigs(configs: Map) = intent { + try { + val tunnelConfigs = + configs.map { (config, name) -> TunnelConf.tunnelConfFromQuick(config, name) } + tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnels) + } catch (_: IOException) { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.read_failed)) + ) + } catch (e: BadConfigException) { + postSideEffect(GlobalSideEffect.Snackbar(e.asStringValue())) + } + } + + fun setTunnelPingIp(ip: String, tunnelId: Int) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save(latestTunnel.copy(pingTarget = ip.ifBlank { null })) + } + } + + fun addTunnelNetwork(tunnelId: Int, ssid: String) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save( + latestTunnel.copy( + tunnelNetworks = latestTunnel.tunnelNetworks.toMutableSet().apply { add(ssid) } + ) + ) + } + } + + fun removeTunnelNetwork(tunnelId: Int, ssid: String) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save( + latestTunnel.copy( + tunnelNetworks = + latestTunnel.tunnelNetworks.toMutableSet().apply { remove(ssid) } + ) + ) + } + } + + fun setRestartOnPing(tunnelId: Int, to: Boolean) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save(latestTunnel.copy(restartOnPingFailure = to)) + } + } + + fun togglePrimaryTunnel(tunnelId: Int) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + val update = if (latestTunnel.isPrimaryTunnel) null else latestTunnel + tunnelRepository.updatePrimaryTunnel(update) + } + } + + fun setMobileDataTunnel(tunnelId: Int, to: Boolean) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save(latestTunnel.copy(isMobileDataTunnel = to)) + } + } + + fun setEthernetTunnel(tunnelId: Int, to: Boolean) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + tunnelRepository.save(latestTunnel.copy(isEthernetTunnel = to)) + } + } + + fun toggleIpv4Preferred(tunnelId: Int) = intent { + val latestTunnel = state.tunnels.find { it.id == tunnelId } + if (latestTunnel != null) { + val update = if (latestTunnel.isPrimaryTunnel) null else latestTunnel + tunnelRepository.updatePrimaryTunnel(update) + } + } + + fun importFromClipboard(conf: String) { + importTunnelConfigs(mapOf(conf to null)) + } + + fun importFromQr(conf: String) = intent { + importFromClipboard(conf) + postSideEffect(GlobalSideEffect.PopBackStack) + } + + fun importFromUrl(url: String) = intent { + runCatching { + val url = URL(url) + val uri = url.toURI().toString().toUri() + importFromUri(uri) + } + .onFailure { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.error_download_failed) + ) + ) + } + } + + fun importFromUri(uri: Uri) = intent { + fileUtils + .readConfigsFromUri(uri) + .onSuccess { configs -> importTunnelConfigs(configs) } + .onFailure { + val message = + when (it) { + is IOException -> StringValue.StringResource(R.string.error_download_failed) + else -> StringValue.StringResource(R.string.error_file_extension) + } + postSideEffect(GlobalSideEffect.Toast(message)) + } + } + + fun toggleSelectAllTunnels() = intent { + if (state.selectedTunnels.size != state.tunnels.size) { + return@intent reduce { state.copy(selectedTunnels = state.tunnels.toSet()) } + } + reduce { state.copy(selectedTunnels = emptySet()) } + } + + fun clearSelectedTunnels() = intent { reduce { state.copy(selectedTunnels = emptySet()) } } + + fun toggleSelectedTunnel(tunnelId: Int) = intent { + reduce { + state.copy( + selectedTunnels = + state.selectedTunnels.toMutableSet().apply { + val removed = removeIf { it.id == tunnelId } + if (!removed) addAll(state.tunnels.filter { it.id == tunnelId }) + } + ) + } + } + + fun deleteSelectedTunnels() = intent { + tunnelRepository.delete(state.selectedTunnels.toList()) + clearSelectedTunnels() + } + + fun copySelectedTunnel() = intent { + val selected = state.selectedTunnels.firstOrNull() ?: return@intent + val copy = TunnelConf.tunnelConfFromQuick(selected.amQuick, selected.name) + tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnels) + clearSelectedTunnels() + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt deleted file mode 100644 index 0fe818ab..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.viewmodel.event - -import android.net.Uri -import com.zaneschepke.networkmonitor.AndroidNetworkMonitor -import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.DnsProtocol -import com.zaneschepke.wireguardautotunnel.data.model.DnsProvider -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState -import com.zaneschepke.wireguardautotunnel.ui.theme.Theme -import com.zaneschepke.wireguardautotunnel.util.StringValue - -sealed class AppEvent { - data class SetScreenAction(val callback: () -> Unit) : AppEvent() - - data object InvokeScreenAction : AppEvent() - - data object ToggleLocalLogging : AppEvent() - - data object ToggleRestartAtBoot : AppEvent() - - data object ToggleVpnKillSwitch : AppEvent() - - data object ToggleLanOnKillSwitch : AppEvent() - - data object ToggleAppShortcuts : AppEvent() - - data object ToggleAlwaysOn : AppEvent() - - data object TogglePinLock : AppEvent() - - data object TogglePingMonitoring : AppEvent() - - data class TogglePrimaryTunnel(val tunnel: TunnelConf) : AppEvent() - - data class ToggleIpv4Preferred(val tunnel: TunnelConf) : AppEvent() - - data class ToggleRestartOnPingFailure(val tunnel: TunnelConf) : AppEvent() - - data class SetTunnelPingTarget(val tunnelConf: TunnelConf, val host: String) : AppEvent() - - data class AddTunnelRunSSID(val ssid: String, val tunnel: TunnelConf) : AppEvent() - - data class DeleteTunnelRunSSID(val ssid: String, val tunnel: TunnelConf) : AppEvent() - - data class ToggleEthernetTunnel(val tunnel: TunnelConf) : AppEvent() - - data class ToggleMobileDataTunnel(val tunnel: TunnelConf) : AppEvent() - - data class SetDebounceDelay(val delay: Int) : AppEvent() - - data object ToggleAutoTunnel : AppEvent() - - data class StartTunnel(val tunnel: TunnelConf) : AppEvent() - - data class StopTunnel(val tunnel: TunnelConf) : AppEvent() - - data object DeleteSelectedTunnels : AppEvent() - - data object CopySelectedTunnel : AppEvent() - - data class ImportTunnelFromFile(val data: Uri) : AppEvent() - - data class ImportTunnelFromClipboard(val text: String) : AppEvent() - - data class ImportTunnelFromUrl(val url: String) : AppEvent() - - data class ImportTunnelFromQrCode(val qrCode: String) : AppEvent() - - data class ToggleTunnelStatsExpanded(val tunnelId: Int) : AppEvent() - - data object SetBatteryOptimizeDisableShown : AppEvent() - - data object SetLocationDisclosureShown : AppEvent() - - data class SetLocale(val localeTag: String) : AppEvent() - - data class SetTheme(val theme: Theme) : AppEvent() - - data class SaveTunnel(val tunnel: TunnelConf) : AppEvent() - - data class SaveMonitoringSettings( - val pingInterval: Int, - val tunnelPingAttempts: Int, - val pingTimeout: Int?, - ) : AppEvent() - - data class SetDetectionMethod(val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod) : - AppEvent() - - data class SetAppMode(val appMode: AppMode) : AppEvent() - - data object ToggleAutoTunnelOnWifi : AppEvent() - - data object ToggleAutoTunnelOnCellular : AppEvent() - - data object ToggleAutoTunnelOnEthernet : AppEvent() - - data object ToggleStopKillSwitchOnTrusted : AppEvent() - - data object ToggleStopTunnelOnNoInternet : AppEvent() - - data object ToggleAutoTunnelWildcards : AppEvent() - - data class DeleteTrustedSSID(val ssid: String) : AppEvent() - - data class SaveTrustedSSID(val ssid: String) : AppEvent() - - data class ExportSelectedTunnels(val configType: ConfigType, val uri: Uri?) : AppEvent() - - data object ExportLogs : AppEvent() - - data object DeleteLogs : AppEvent() - - data object MessageShown : AppEvent() - - data class ShowMessage(val message: StringValue) : AppEvent() - - data class PopBackStack(val pop: Boolean) : AppEvent() - - data class SetBottomSheet(val showSheet: AppViewState.BottomSheet) : AppEvent() - - data class ToggleSelectedTunnel(val tunnel: TunnelConf) : AppEvent() - - data object ClearSelectedTunnels : AppEvent() - - data object VpnPermissionRequested : AppEvent() - - data class AppReadyCheck(val tunnels: List) : AppEvent() - - data object ToggleRemoteControl : AppEvent() - - data class SetShowModal(val modalType: AppViewState.ModalType) : AppEvent() - - data object ToggleSelectAllTunnels : AppEvent() - - data class SaveAllConfigs(val tunnels: List) : AppEvent() - - data object ToggleShowDetailedPingStats : AppEvent() - - data class SetPingAttempts(val count: Int) : AppEvent() - - data class SetPingTimeout(val timeout: Int?) : AppEvent() - - data class SetPingInterval(val interval: Int) : AppEvent() - - data class SetDnsProtocol(val dnsProtocol: DnsProtocol) : AppEvent() - - data class SetDnsProvider(val dnsProvider: DnsProvider) : AppEvent() - - data class SetProxySettings( - val socks5Enabled: Boolean, - val httpProxyEnabled: Boolean, - val httpBindAddress: String?, - val socks5BindAddress: String?, - val username: String, - val password: String, - ) : AppEvent() -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt deleted file mode 100644 index 2dc31cfc..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/UiEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.viewmodel.event - -sealed class UiEvent { - data object SortTunnels : UiEvent() -} diff --git a/app/src/main/res/drawable/resource_package.xml b/app/src/main/res/drawable/resource_package.xml new file mode 100644 index 00000000..123792fa --- /dev/null +++ b/app/src/main/res/drawable/resource_package.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fb132cd..fe7ee436 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,8 +89,8 @@ (Auto) Pin is incorrect Pin successfully created - Enter your pin - Create pin + Enter PIN + Create PIN Enable app lock Restart on ping fail (beta) Set as mobile data tunnel @@ -364,4 +364,21 @@ Server:Port New tunnel System features + Failed to read data. + Invalid key + Invalid number + Invalid value + Missing attribute + Missing section + Syntax error + Unknown attribute + Unknown section + Bad config. %1$s at location: %2$s. + Failed. Proxies must have different ports. + Password cannot have spaces. + Tunnel name is already used. + (%1$d–%2$d) + Mimic QUIC + Mimic DNS + Mimic SIP diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b211a74..a6e5b3e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] accompanist = "0.37.3" activityCompose = "1.10.1" -amneziawgAndroid = "2.0.4" +amneziawgAndroid = "2.0.5" androidx-junit = "1.3.0" icmp4a = "1.0.0" +orbitCompose = "10.0.0" roomdatabasebackup = "1.1.0" shizuku = "13.1.5" appcompat = "1.7.1" @@ -20,7 +21,7 @@ ktorClientCore = "3.2.3" lifecycle-runtime-compose = "2.9.2" material3 = "1.3.2" navigationCompose = "2.9.3" -pinLockCompose = "1.0.4" +pinLockCompose = "1.0.5" qrose = "1.0.1" roomVersion = "2.7.2" semver4j = "3.1.0" @@ -104,7 +105,11 @@ lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-com material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "icons" } material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "icons" } -# util +# state +orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbitCompose" } +orbit-core = { module = "org.orbit-mvi:orbit-core", version.ref = "orbitCompose" } +orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbitCompose" } + pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref = "pinLockCompose" } roomdatabasebackup = { module = "de.raphaelebner:roomdatabasebackup", version.ref = "roomdatabasebackup" } shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }