From 38ecb0b66b3b57129cfb9c5d7a2f287d4bb3af79 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Thu, 7 Aug 2025 18:19:36 -0400 Subject: [PATCH] feat!: tun monitoring, move ping restarts to auto-tunnel w/recovery (#885) This is a big one.. oops. Main changes: - Make ping monitor more robust and global, with ping target overrides of the default cloudflare fallback target per tunnel (for full tunnels, otherwise we ping the internal tun ip) - Include ping restart recovery to prevent tun being down if dns failures happen after a bounce - Ping monitoring itself remains per tunnel and works without auto tunnel active, but moves the restart feature back to be managed by and integrated with auto tunnel to prevent inconsistencies and conflicts - Ping statistics can be optionally included to be displayed with tun statistics - Adds the beginnings of monitoring logs for handshake and data packet failures for userspace tuns (to be incorporated with restarts/tun status later) - Improve tun error notifications, adds ping restart notifications - Major refactor of auto tunnel logic to make it more modular and extensible for new auto tunnel conditions - A bunch of other stuff.. --- app/build.gradle.kts | 8 +- .../19.json | 316 +++++++++++++++ .../wireguardautotunnel/MainActivity.kt | 11 +- .../WireGuardAutoTunnel.kt | 10 +- .../core/notification/NotificationManager.kt | 3 +- .../core/notification/NotificationMonitor.kt | 63 +++ .../core/service/ServiceManager.kt | 9 +- .../core/service/TunnelForegroundService.kt | 212 ++-------- .../service/autotunnel/AutoTunnelService.kt | 381 ++++++++++++------ .../core/service/autotunnel/StateChange.kt | 20 + .../core/tunnel/BaseTunnel.kt | 183 ++++++--- .../core/tunnel/KernelTunnel.kt | 6 +- .../core/tunnel/TunnelManager.kt | 138 +++---- .../core/tunnel/TunnelMonitor.kt | 266 ++++++++++++ .../core/tunnel/TunnelProvider.kt | 23 +- .../core/tunnel/UserspaceTunnel.kt | 7 +- .../core/worker/ServiceWorker.kt | 6 +- .../wireguardautotunnel/data/AppDatabase.kt | 28 +- .../data/DataStoreManager.kt | 1 + .../data/DatabaseConverters.kt | 8 +- .../data/dao/SettingsDao.kt | 6 +- .../data/dao/TunnelConfigDao.kt | 6 +- .../data/entity/GeneralState.kt | 2 + .../data/entity/Settings.kt | 12 +- .../data/entity/TunnelConfig.kt | 12 +- .../data/mapper/GeneralStateMapper.kt | 2 + .../data/mapper/GitHubReleaseMapper.kt | 1 - .../data/mapper/SettingsMapper.kt | 10 +- .../data/mapper/TunnelConfigMapper.kt | 14 +- .../data/network/KtorClient.kt | 10 +- .../data/network/KtorGitHubApi.kt | 10 +- .../repository/DataStoreAppStateRepository.kt | 12 + .../data/repository/GitHubUpdateRepository.kt | 12 +- .../wireguardautotunnel/di/AppModule.kt | 18 + .../di/RepositoryModule.kt | 14 +- .../wireguardautotunnel/di/TunnelModule.kt | 27 +- .../domain/enums/TunnelStatus.kt | 10 +- .../domain/events/AutoTunnelEvent.kt | 7 + .../domain/{enums => events}/BackendError.kt | 17 +- .../domain/events/BackendMessage.kt | 19 + .../domain/model/AppSettings.kt | 8 +- .../domain/model/AppState.kt | 1 + .../domain/model/TunnelConf.kt | 42 +- .../domain/repository/AppStateRepository.kt | 4 + .../domain/state/AutoTunnelState.kt | 202 ++++------ .../domain/state/PingState.kt | 26 ++ .../domain/state/TunnelState.kt | 3 + .../domain/state/TunnelStatistics.kt | 17 +- .../wireguardautotunnel/ui/Route.kt | 2 + .../ui/common/animation/ShimmerEffect.kt | 6 +- .../ui/common/button/ClickableIconButton.kt | 6 +- .../ui/common/button/SelectionItemButton.kt | 13 +- .../config/SubmitConfigurationTextBox.kt | 8 +- .../ui/common/dialog/InfoDialog.kt | 6 +- .../common/{ => dropdown}/DropdownSelector.kt | 30 +- .../common/dropdown/LabelledNumberDropdown.kt | 36 ++ .../ui/common/snackbar/CustomSnackbar.kt | 8 +- .../currentNavBackStackEntryAsNavBarState.kt | 6 + .../ui/screens/autotunnel/AutoTunnelScreen.kt | 6 +- .../advanced/AutoTunnelAdvancedScreen.kt | 29 +- .../components/DebounceDelaySelector.kt | 50 --- .../components/TrustNetworksTextBox.kt | 7 +- .../main/autotunnel/TunnelAutoTunnelScreen.kt | 11 +- .../components/PingRestartItem.kt | 8 +- .../autotunnel/components/WifiTunnelItem.kt | 6 +- .../main/components/TunnelImportSheet.kt | 14 +- .../ui/screens/main/components/TunnelList.kt | 2 + .../screens/main/components/TunnelRowItem.kt | 16 +- .../main/components/TunnelStatisticsRow.kt | 255 ++++++++---- .../main/components/UrlImportDialog.kt | 6 +- .../config/components/InterfaceDropdown.kt | 7 +- .../main/config/components/InterfaceFields.kt | 6 +- .../config/components/InterfaceSection.kt | 12 +- .../main/config/components/PeersSection.kt | 21 +- .../main/splittunnel/SplitTunnelViewModel.kt | 6 +- .../splittunnel/components/AppListItem.kt | 6 +- .../splittunnel/components/AppListSection.kt | 7 +- .../components/SplitOptionsSelector.kt | 7 +- .../main/tunneloptions/TunnelOptionsScreen.kt | 21 +- .../components/PingConfigItem.kt | 72 ---- .../tunneloptions/components/QrCodeDialog.kt | 33 +- .../components/pingConfigItem.kt | 40 ++ .../ui/screens/settings/SettingsScreen.kt | 15 +- ...{AppShortcutItem.kt => appShortcutItem.kt} | 2 +- .../{KernelModeItem.kt => kernelModeItem.kt} | 2 +- .../components/tunnelMonitoringItem.kt | 31 ++ .../ui/screens/settings/logs/LogsScreen.kt | 9 +- .../logs/components/LogsBottomSheet.kt | 7 +- .../monitoring/TunnelMonitoringScreen.kt | 117 ++++++ .../components/detailedPingStatsItem.kt | 36 ++ .../components/enablePingMonitoringItem.kt | 36 ++ .../ui/screens/support/SupportScreen.kt | 6 +- .../components/GeneralSupportOptions.kt | 3 - .../support/components/UpdateSection.kt | 2 - .../screens/support/license/LicenseScreen.kt | 7 +- .../wireguardautotunnel/util/Constants.kt | 7 +- .../util/extensions/Extensions.kt | 11 + .../util/extensions/TunnelExtensions.kt | 16 +- .../util/network/NetworkUtils.kt | 84 ++++ .../util/network/PingStats.kt | 26 ++ .../viewmodel/AppViewModel.kt | 186 ++++++--- .../viewmodel/event/AppEvent.kt | 24 +- app/src/main/res/values/strings.xml | 39 +- buildSrc/src/main/kotlin/LocalProperties.kt | 2 +- gradle/libs.versions.toml | 2 + .../zaneschepke/logcatter/LogcatManager.kt | 8 +- .../networkmonitor/ActiveWifiStateManager.kt | 39 -- .../networkmonitor/AndroidNetworkMonitor.kt | 14 +- 108 files changed, 2415 insertions(+), 1308 deletions(-) create mode 100644 app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelMonitor.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/{enums => events}/BackendError.kt (59%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/{ => dropdown}/DropdownSelector.kt (68%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/components/DebounceDelaySelector.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/{tunneloptions => autotunnel}/components/PingRestartItem.kt (82%) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PingConfigItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/pingConfigItem.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/{AppShortcutItem.kt => appShortcutItem.kt} (95%) rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/{KernelModeItem.kt => kernelModeItem.kt} (96%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/PingStats.kt delete mode 100644 networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ActiveWifiStateManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd2df97f..814500a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,7 +107,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") + } + } buildFeatures { compose = true @@ -213,6 +218,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.slf4j.android) + implementation(libs.icmp4a) // shizuku implementation(libs.shizuku.api) diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json new file mode 100644 index 00000000..6a4293fb --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/19.json @@ -0,0 +1,316 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "82bdb96b7a9f8695a34ad1ec21d9aea8", + "entities": [ + { + "tableName": "Settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT false, `is_vpn_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_disable_kill_switch_on_trusted_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT false, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT true, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAutoTunnelEnabled", + "columnName": "is_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTunnelOnMobileDataEnabled", + "columnName": "is_tunnel_on_mobile_data_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trustedNetworkSSIDs", + "columnName": "trusted_network_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAlwaysOnVpnEnabled", + "columnName": "is_always_on_vpn_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTunnelOnEthernetEnabled", + "columnName": "is_tunnel_on_ethernet_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShortcutsEnabled", + "columnName": "is_shortcuts_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isTunnelOnWifiEnabled", + "columnName": "is_tunnel_on_wifi_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isKernelEnabled", + "columnName": "is_kernel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isRestoreOnBootEnabled", + "columnName": "is_restore_on_boot_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isMultiTunnelEnabled", + "columnName": "is_multi_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isPingEnabled", + "columnName": "is_ping_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isAmneziaEnabled", + "columnName": "is_amnezia_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isWildcardsEnabled", + "columnName": "is_wildcards_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isStopOnNoInternetEnabled", + "columnName": "is_stop_on_no_internet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isVpnKillSwitchEnabled", + "columnName": "is_vpn_kill_switch_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isKernelKillSwitchEnabled", + "columnName": "is_kernel_kill_switch_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isLanOnKillSwitchEnabled", + "columnName": "is_lan_on_kill_switch_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "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": "false" + }, + { + "fieldPath": "isTunnelOnUnsecureEnabled", + "columnName": "is_tunnel_on_unsecure_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "wifiDetectionMethod", + "columnName": "wifi_detection_method", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPingMonitoringEnabled", + "columnName": "is_ping_monitoring_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "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" + } + ], + "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`)" + } + ] + } + ], + "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, '82bdb96b7a9f8695a34ad1ec21d9aea8')" + ] + } +} \ 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 8dc8720d..b02342c8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -62,6 +62,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.displa import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.LogsScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.TunnelMonitoringScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.LicenseScreen import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme @@ -289,7 +290,12 @@ class MainActivity : AppCompatActivity() { appUiState.tunnels .firstOrNull { it.id == args.id } ?.let { config -> - TunnelOptionsScreen(config, viewModel, appViewState) + TunnelOptionsScreen( + config, + viewModel, + appViewState, + appUiState.appSettings, + ) } } composable { PinLockScreen(viewModel) } @@ -310,6 +316,9 @@ class MainActivity : AppCompatActivity() { } } composable { SortScreen(appUiState, viewModel) } + composable { + TunnelMonitoringScreen(appUiState, viewModel) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 62c677b9..b157d41d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -7,6 +7,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.wireguard.android.backend.GoBackend import com.zaneschepke.logcatter.LogReader +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.worker.ServiceWorker import com.zaneschepke.wireguardautotunnel.di.ApplicationScope @@ -18,14 +19,10 @@ import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber @HiltAndroidApp @@ -46,6 +43,8 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider { @Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher + @Inject lateinit var notificationMonitor: NotificationMonitor + @Inject lateinit var tunnelManager: TunnelManager override fun onCreate() { @@ -80,6 +79,7 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider { ServiceWorker.start(this) applicationScope.launch { + launch { notificationMonitor.handleApplicationNotifications() } appDataRepository.appState.getLocale()?.let { withContext(mainDispatcher) { LocaleUtil.changeLocale(it) } } 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 d12cd422..258d45a9 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 @@ -49,7 +49,8 @@ interface NotificationManager { const val AUTO_TUNNEL_NOTIFICATION_ID = 122 // for tunnel foreground notification const val VPN_NOTIFICATION_ID = 100 - const val TUNNEL_STATUS_NOTIFICATION_ID = 101 + const val TUNNEL_ERROR_NOTIFICATION_ID = 101 + const val TUNNEL_MESSAGES_NOTIFICATION_ID = 102 const val EXTRA_ID = "id" } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt new file mode 100644 index 00000000..c1b25800 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt @@ -0,0 +1,63 @@ +package com.zaneschepke.wireguardautotunnel.core.notification + +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError +import com.zaneschepke.wireguardautotunnel.util.StringValue +import jakarta.inject.Inject +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class NotificationMonitor +@Inject +constructor( + private val tunnelManager: TunnelManager, + private val notificationManager: NotificationManager, +) { + suspend fun handleApplicationNotifications() = coroutineScope { + launch { handleTunnelErrors() } + launch { handleTunnelMessages() } + } + + private suspend fun handleTunnelErrors() = + tunnelManager.errorEvents.collectLatest { (tunnelConf, error) -> + if (!WireGuardAutoTunnel.uiActive.value) { + val notification = + notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = StringValue.DynamicString(tunnelConf.name), + description = + when (error) { + is BackendError.BounceFailed -> error.toStringValue() + else -> + StringValue.StringResource( + R.string.tunnel_error_template, + error.toStringRes(), + ) + }, + ) + notificationManager.show( + NotificationManager.TUNNEL_ERROR_NOTIFICATION_ID, + notification, + ) + } + } + + private suspend fun handleTunnelMessages() = + tunnelManager.messageEvents.collectLatest { (tunnelConf, message) -> + if (!WireGuardAutoTunnel.uiActive.value) { + val notification = + notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = StringValue.DynamicString(tunnelConf.name), + description = message.toStringValue(), + ) + notificationManager.show( + NotificationManager.TUNNEL_MESSAGES_NOTIFICATION_ID, + notification, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt index 5b5c3939..6b429cfc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt @@ -7,9 +7,6 @@ import android.content.ServiceConnection import android.net.VpnService import android.os.IBinder import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService -import com.zaneschepke.wireguardautotunnel.di.ApplicationScope -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.di.MainDispatcher import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate @@ -29,9 +26,9 @@ class ServiceManager @Inject constructor( private val context: Context, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - @ApplicationScope private val applicationScope: CoroutineScope, - @MainDispatcher private val mainDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher, + private val applicationScope: CoroutineScope, + private val mainDispatcher: CoroutineDispatcher, private val appDataRepository: AppDataRepository, ) { 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 1ac97fd4..365607d6 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 @@ -7,26 +7,24 @@ import android.os.IBinder import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope -import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys import dagger.hilt.android.AndroidEntryPoint -import java.util.concurrent.ConcurrentHashMap +import io.ktor.util.collections.* import javax.inject.Inject -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint @@ -36,23 +34,18 @@ class TunnelForegroundService : LifecycleService() { @Inject lateinit var serviceManager: ServiceManager - @Inject lateinit var networkMonitor: NetworkMonitor + @Inject lateinit var tunnelManager: TunnelManager + + @Inject lateinit var tunnelMonitor: TunnelMonitor @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher - @Inject lateinit var tunnelRepo: TunnelRepository - - @Inject lateinit var tunnelManager: TunnelManager - - private val isNetworkConnected = MutableStateFlow(true) - - private val tunnelJobs = ConcurrentHashMap() - private val pingJobs = ConcurrentHashMap() - - private val jobsMutex = Mutex() + @Inject lateinit var appDataRepository: AppDataRepository class LocalBinder(val service: TunnelForegroundService) : Binder() + private val tunnelJobs = ConcurrentMap() + private val binder = LocalBinder(this) override fun onCreate() { @@ -85,94 +78,23 @@ class TunnelForegroundService : LifecycleService() { fun start() = lifecycleScope.launch(ioDispatcher) { tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels -> - // No active tunnels and no jobs: nothing to do - if (activeTunnels.isEmpty() && tunnelJobs.isEmpty()) return@collect - - // Synchronize jobs with active tunnels - synchronizeJobs(activeTunnels) - updateServiceNotification() - } - } - - private suspend fun synchronizeJobs(activeTunnels: Map) { - jobsMutex.withLock { - // Stop jobs for tunnels that are no longer active - stopInactiveJobs(activeTunnels) - // Start jobs for new tunnels - startNewJobs(activeTunnels) - } - } - - private fun stopInactiveJobs(activeTunnels: Map) { - // If no active tunnels, clear all jobs - if (activeTunnels.isEmpty()) { - clearAllJobs() - return - } - // Stop jobs for tunnels not in activeTunnels - val tunnelsToStop = tunnelJobs.keys - activeTunnels.keys - tunnelsToStop.forEach { tun -> stopTunnelJobs(tun) } - } - - private fun clearAllJobs() { - tunnelJobs.forEach { (tun, job) -> - Timber.d("Stopping tunnel job for ${tun.tunName}") - job.cancel() - } - tunnelJobs.clear() - - pingJobs.forEach { (tun, job) -> - if (isPingBounce(tun)) { - Timber.d("Preserving ping job for ${tun.tunName} due to PING bounce") - return@forEach - } - Timber.d("Stopping ping job for ${tun.tunName}") - job.cancel() - } - pingJobs.entries.removeIf { (tun, _) -> !isPingBounce(tun) } - } - - private fun stopTunnelJobs(tun: TunnelConf) { - tunnelJobs.remove(tun)?.cancel() - Timber.d("Stopped tunnel job for ${tun.tunName}") - if (isPingBounce(tun)) - return Timber.d("Preserving ${tun.tunName} ping job due to ping bounce") - pingJobs.remove(tun)?.cancel() - Timber.d("Stopped ping job for ${tun.tunName}") - } - - private fun startNewJobs(activeTunnels: Map) { - val tunnelsToStart = activeTunnels.keys - tunnelJobs.keys - tunnelsToStart.forEach { tun -> - tunnelJobs[tun] = startTunnelJobs(tun) - Timber.d("Started tunnel job for ${tun.tunName}") - - if (pingJobs[tun]?.isActive == true) { - Timber.d("Reusing active ping job for ${tun.tunName}") - } else { - pingJobs[tun]?.cancel() // Cancel any stale job - if (tun.isPingEnabled) { - if (tun.isStaticallyConfigured()) { - Timber.d("Skipping ping for statically configured tunnel") - } else { - pingJobs[tun] = startPingJob(tun) - Timber.d("Started ping job for ${tun.tunName}") - } + val activeTunConfigs = activeTunnels.keys + val obsoleteJobs = tunnelJobs.keys - activeTunConfigs + obsoleteJobs.forEach { tunnelConf -> tunnelJobs[tunnelConf]?.cancel() } + activeTunConfigs.forEach { tun -> + if (tunnelJobs.containsKey(tun)) return@forEach + tunnelJobs[tun] = launch { tunnelMonitor.startMonitoring(tun, true) } } + updateServiceNotification(activeTunnels) } } - } - - private fun isPingBounce(tun: TunnelConf): Boolean = - tunnelManager.bouncingTunnelIds[tun.id] == TunnelStatus.StopReason.PING // TODO Would be cool to have this include kill switch - // TODO also we need to include errors - private fun updateServiceNotification() { + private fun updateServiceNotification(activeTunnels: Map) { val notification = - when (tunnelJobs.size) { + when (activeTunnels.size) { 0 -> onCreateNotification() - 1 -> createTunnelNotification(tunnelJobs.keys.first()) + 1 -> createTunnelNotification(activeTunnels.keys.first()) else -> createTunnelsNotification() } ServiceCompat.startForeground( @@ -183,90 +105,18 @@ class TunnelForegroundService : LifecycleService() { ) } - // use same scope so we can cancel all of these - private fun startTunnelJobs(tunnelConf: TunnelConf) = - lifecycleScope.launch(ioDispatcher) { - // monitor if we have internet connectivity - launch { startNetworkMonitorJob() } - // job to trigger stats emit on interval - launch { startTunnelStatsJob(tunnelConf) } - // monitor changes to the tunnel config - launch { startTunnelConfChangesJob(tunnelConf) } - } - - private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) { - tunnelRepo.flow - .flowOn(ioDispatcher) - .map { storedTunnels -> storedTunnels.firstOrNull { it.id == tunnelConf.id } } - .filterNotNull() - // only emit when one of these 3 values change - .distinctUntilChanged { old, new -> old == new } - .collect { storedTunnel -> - if (tunnelConf != storedTunnel) { - Timber.d("Config changed for ${storedTunnel.tunName}, bouncing") - // let this complete, even after cancel - withContext(NonCancellable) { - tunnelManager.bounceTunnel( - storedTunnel, - TunnelStatus.StopReason.CONFIG_CHANGED, - ) - } - } - } - } - - private suspend fun startNetworkMonitorJob() { - networkMonitor.connectivityStateFlow.flowOn(ioDispatcher).collectLatest { status -> - isNetworkConnected.value = status.hasConnectivity() - Timber.d("Network available: $status") - } - } - - private suspend fun startTunnelStatsJob(tunnel: TunnelConf) = coroutineScope { - while (isActive) { - tunnelManager.updateTunnelStatistics(tunnel) - delay(STATS_DELAY) - } - } - - private fun startPingJob(tunnel: TunnelConf) = - lifecycleScope.launch(ioDispatcher) { - // delay for initial duration - delay(tunnel.pingInterval ?: Constants.PING_INTERVAL) - while (isActive) { - val shouldBounce = shouldBounceTunnel(tunnel) - val delayMs = - if (shouldBounce) { - // let this complete, even after cancel - withContext(NonCancellable) { - tunnelManager.bounceTunnel(tunnel, TunnelStatus.StopReason.PING) - } - tunnel.pingCooldown ?: Constants.PING_COOLDOWN - } else { - tunnel.pingInterval ?: Constants.PING_INTERVAL - } - delay(delayMs) - } - } - - private suspend fun shouldBounceTunnel(tunnel: TunnelConf): Boolean { - if (!isNetworkConnected.value) { - Timber.d("Network disconnected, skipping ping for ${tunnel.tunName}") - return false - } - return runCatching { !tunnel.isTunnelPingable(ioDispatcher) } - .onFailure { e -> Timber.e(e, "Ping check failed for ${tunnel.tunName}") } - .getOrDefault(true) - } - fun stop() { + Timber.d("Stop called") + tunnelJobs.forEach { it.value.cancel() } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } override fun onDestroy() { + tunnelJobs.forEach { it.value.cancel() } serviceManager.handleTunnelServiceDestroy() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + Timber.d("onDestroy") super.onDestroy() } @@ -301,14 +151,4 @@ class TunnelForegroundService : LifecycleService() { title = getString(R.string.tunnel_starting), ) } - - // TODO add notification handling and optional log reading for restart on handshake failures - companion object { - const val STATS_DELAY = 1_000L - // ipv6 disabled or block on network - // Failed to send handshake initiation: write udp [::]" - // Failed to send data packets: write udp [::] - // Failed to send data packets: write udp 0.0.0.0:51820 - // Handshake did not complete after 5 seconds, retrying - } } 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 ee6704c4..1987f1fe 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 @@ -7,17 +7,19 @@ import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import com.zaneschepke.networkmonitor.AndroidNetworkMonitor +import com.zaneschepke.networkmonitor.ConnectivityState import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState 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.events.KillSwitchEvent import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository @@ -25,12 +27,15 @@ 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.zipWithPrevious +import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import javax.inject.Provider +import kotlin.math.pow import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber @AndroidEntryPoint @@ -48,11 +53,19 @@ class AutoTunnelService : LifecycleService() { @Inject lateinit var tunnelManager: TunnelManager + @Inject lateinit var tunnelMonitor: TunnelMonitor + private val defaultState = AutoTunnelState() + private val autoTunMutex = Mutex() + private val autoTunnelStateFlow = MutableStateFlow(defaultState) - private var killSwitchJob: Job? = null + private val bounceCounts = MutableStateFlow>(emptyMap()) + + private var eventHandlerJob: Job? = null + + private val lastBounceTimes = mutableMapOf() class LocalBinder(val service: AutoTunnelService) : Binder() @@ -77,10 +90,8 @@ class AutoTunnelService : LifecycleService() { fun start() { launchWatcherNotification() - startAutoTunnelJob() startAutoTunnelStateJob() - killSwitchJob = startKillSwitchJob() - startNotificationJob() + startLocationPermissionsNotificationJob() } fun stop() { @@ -90,6 +101,7 @@ class AutoTunnelService : LifecycleService() { override fun onDestroy() { serviceManager.handleAutoTunnelServiceDestroy() restoreVpnKillSwitch() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -99,7 +111,7 @@ class AutoTunnelService : LifecycleService() { settings.isVpnKillSwitchEnabled && tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE ) { - killSwitchJob?.cancel() + eventHandlerJob?.cancel() val allowedIps = if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList() @@ -131,47 +143,141 @@ class AutoTunnelService : LifecycleService() { ) } - @OptIn(ExperimentalCoroutinesApi::class) private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) { - combine( - combineSettings(), - appDataRepository - .get() - .settings - .flow - .distinctUntilChanged { old, new -> - old.isKernelEnabled == new.isKernelEnabled - } // Only emit when isKernelEnabled changes - .flatMapLatest { - networkMonitor.connectivityStateFlow - .flowOn(ioDispatcher) - .map(NetworkState::from) - } - .distinctUntilChanged(), - ) { double, networkState -> - AutoTunnelState( - tunnelManager.activeTunnels.value, - networkState, - double.first, - double.second, - ) - } - .collect { state -> + val networkFlow = + debouncedConnectivityStateFlow + .flowOn(ioDispatcher) + .map(NetworkState::from) + .map { StateChange.NetworkChange(it) } + .distinctUntilChanged() + + val settingsFlow = + combineSettings().map { StateChange.SettingsChange(it.first, it.second) } + + val tunnelsFlow = + tunnelManager.activeTunnels.map { StateChange.ActiveTunnelsChange(it) } + + val monitoringFlow = + tunnelManager.activeTunnels + .map { map -> map.mapValues { (_, state) -> state.pingStates } } + .distinctUntilChanged() + .map { StateChange.MonitoringChange(it) } + + var reevaluationJob: Job? = null + + // get everything in sync before we use merge + combine(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow) { + network, + settings, + tunnels, + monitoring -> autoTunnelStateFlow.update { it.copy( - activeTunnels = state.activeTunnels, - networkState = state.networkState, - settings = state.settings, - tunnels = state.tunnels, + activeTunnels = tunnels.activeTunnels, + networkState = network.networkState, + settings = settings.settings, + tunnels = settings.tunnels, ) } } + .first() + + // use merge to limit the noise of a combine and also increase the scalability of auto + // tunnel handling new states + merge(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow).collect { change -> + if (change !is StateChange.ActiveTunnelsChange) { + Timber.d("New state changed to ${change.javaClass.simpleName}") + } + + when (change) { + is StateChange.NetworkChange -> { + reevaluationJob?.cancel() + val previousState = autoTunnelStateFlow.value + autoTunnelStateFlow.update { it.copy(networkState = change.networkState) } + // Android late mobile data state change, we can ignore handling this + if ( + isAndroidLateCellularActiveChange( + previousState.networkState, + change.networkState, + ) + ) { + Timber.d("Android late cellular active state change") + return@collect + } + } + is StateChange.SettingsChange -> { + reevaluationJob?.cancel() + autoTunnelStateFlow.update { + it.copy(settings = change.settings, tunnels = change.tunnels) + } + } + is StateChange.ActiveTunnelsChange -> { + autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) } + return@collect + } + is StateChange.MonitoringChange -> { + change.pingStates.forEach { (config, pingState) -> + Timber.d("Ping state $pingState") + if (pingState?.all { it.value.isReachable } == true) { + Timber.d("Clearing bounce count on success") + bounceCounts.update { current -> + current.toMutableMap().apply { remove(config.id) } + } + } + } + return@collect handleAutoTunnelEvent( + autoTunnelStateFlow.value.determineAutoTunnelEvent( + StateChange.MonitoringChange(change.pingStates) + ) + ) + } + } + + handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change)) + + reevaluationJob = launch { + delay(REEVALUATE_CHECK_DELAY) + val currentState = autoTunnelStateFlow.value + if (currentState != defaultState) { + Timber.d("Re-evaluating auto-tunnel state..") + handleAutoTunnelEvent(currentState.determineAutoTunnelEvent(change)) + } + } + } } + private fun isAndroidLateCellularActiveChange( + previous: NetworkState, + new: NetworkState, + ): Boolean { + return (previous.isWifiConnected != new.isWifiConnected && + previous.wifiName == new.wifiName && + previous.isMobileDataConnected != new.isMobileDataConnected) + } + + // all relevant settings to auto tunnel + private fun areAutoTunnelSettingsTheSame(old: AppSettings, new: AppSettings): Boolean { + return (old.isTunnelOnWifiEnabled == new.isTunnelOnWifiEnabled && + old.isTunnelOnMobileDataEnabled == new.isTunnelOnMobileDataEnabled && + old.isTunnelOnEthernetEnabled == new.isTunnelOnEthernetEnabled && + old.trustedNetworkSSIDs == new.trustedNetworkSSIDs && + old.isPingEnabled == new.isPingEnabled && + old.debounceDelaySeconds == new.debounceDelaySeconds && + old.wifiDetectionMethod == new.wifiDetectionMethod && + old.isVpnKillSwitchEnabled == new.isVpnKillSwitchEnabled && + old.isLanOnKillSwitchEnabled == new.isLanOnKillSwitchEnabled && + old.isDisableKillSwitchOnTrustedEnabled == new.isDisableKillSwitchOnTrustedEnabled && + old.isStopOnNoInternetEnabled == new.isStopOnNoInternetEnabled) + } + private fun combineSettings(): Flow> { return combine( - appDataRepository.get().settings.flow, + appDataRepository + .get() + .settings + .flow + .distinctUntilChanged(::areAutoTunnelSettingsTheSame), appDataRepository.get().tunnels.flow.map { tunnels -> // isActive is ignored for equality checks so user can manually toggle off // tunnel with auto-tunnel @@ -183,25 +289,53 @@ class AutoTunnelService : LifecycleService() { .distinctUntilChanged() } - // watch for changes to location permission and notify user it will impact auto-tunneling - // TODO can add deeplinks later back to the app for fixing - // TODO or a recheck button for location permission so we dont have to poll it - private fun startNotificationJob(): Job = + private fun areAutoTunnelPermissionsRequiredTheSame( + old: AutoTunnelState, + new: AutoTunnelState, + ): Boolean { + return (old.settings.wifiDetectionMethod == new.settings.wifiDetectionMethod && + old.networkState.locationPermissionGranted == + new.networkState.locationPermissionGranted && + old.networkState.locationServicesEnabled == new.networkState.locationServicesEnabled && + old.tunnels == new.tunnels && + old.settings.trustedNetworkSSIDs == new.settings.trustedNetworkSSIDs) + } + + // watch for changes to location permission and notify user it will impact auto-tunneling + // TODO or a recheck button for location permission so we dont have to poll it + private fun startLocationPermissionsNotificationJob(): Job = lifecycleScope.launch(ioDispatcher) { var locationServicesShown = false var locationPermissionsShown = false - autoTunnelStateFlow.zipWithPrevious().collect { (previous, current) -> - when (current.settings.wifiDetectionMethod) { - AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT, - AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> { - with(current.networkState) { + + data class NetworkPermissionState( + val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod, + val locationServicesEnabled: Boolean, + val locationPermissionsEnabled: Boolean, + val ssidReadRequired: Boolean, + ) + + autoTunnelStateFlow + .distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame) + .map { + NetworkPermissionState( + it.settings.wifiDetectionMethod, + it.networkState.locationServicesEnabled == true, + it.networkState.locationPermissionGranted == true, + (it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } || + it.settings.trustedNetworkSSIDs.isNotEmpty()), + ) + } + .collect { state -> + when (state.detectionMethod) { + AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT, + AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> { if ( - locationPermissionGranted == false && - (previous?.networkState?.locationPermissionGranted == true || - !locationServicesShown) + !state.locationPermissionsEnabled && + !locationPermissionsShown && + state.ssidReadRequired ) { - locationServicesShown = true - Timber.i("Detected location permission lost") + locationPermissionsShown = true val notification = notificationManager.createNotification( WireGuardNotification.NotificationChannels.AUTO_TUNNEL, @@ -215,12 +349,11 @@ class AutoTunnelService : LifecycleService() { ) } if ( - locationServicesEnabled == false && - (previous?.networkState?.locationServicesEnabled == true || - !locationPermissionsShown) + !state.locationServicesEnabled && + !locationServicesShown && + state.ssidReadRequired ) { - locationPermissionsShown = true - Timber.i("Detected location services lost") + locationServicesShown = true val notification = notificationManager.createNotification( WireGuardNotification.NotificationChannels.AUTO_TUNNEL, @@ -233,93 +366,97 @@ class AutoTunnelService : LifecycleService() { notification, ) } - if ( - locationServicesEnabled == true && - previous?.networkState?.locationServicesEnabled == false - ) { + if (state.locationServicesEnabled || !state.ssidReadRequired) { notificationManager.remove( NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID ) + locationServicesShown = false } - if ( - locationPermissionGranted == true && - previous?.networkState?.locationPermissionGranted == false - ) { + if (state.locationPermissionsEnabled || !state.ssidReadRequired) { notificationManager.remove( NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID ) + locationPermissionsShown = false } } + else -> Unit } - else -> Unit + } + } + + private suspend fun handleAutoTunnelEvent(autoTunnelEvent: AutoTunnelEvent) { + autoTunMutex.withLock { + when ( + val event = + autoTunnelEvent.also { + Timber.i("Auto tunnel event: ${it.javaClass.simpleName}") + } + ) { + is AutoTunnelEvent.Start -> + (event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let { + tunnelManager.startTunnel(it) + } + is AutoTunnelEvent.Stop -> tunnelManager.stopTunnel() + AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do") + is AutoTunnelEvent.Bounce -> + handleBounceWithBackoff(event.configsPeerKeyResolvedMap) + is AutoTunnelEvent.StartKillSwitch -> { + Timber.d("Starting kill switch") + tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, event.allowedIps) + } + AutoTunnelEvent.StopKillSwitch -> { + Timber.d("Stopping kill switch") + tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptySet()) } } } + } - private fun startKillSwitchJob() = - lifecycleScope.launch(ioDispatcher) { - autoTunnelStateFlow.collect { - if (it == defaultState) return@collect - when (val event = it.asKillSwitchEvent()) { - KillSwitchEvent.DoNothing -> Unit - is KillSwitchEvent.Start -> { - Timber.d("Starting kill switch") - tunnelManager.setBackendState( - BackendState.KILL_SWITCH_ACTIVE, - event.allowedIps, - ) - } - KillSwitchEvent.Stop -> { - Timber.d("Stopping kill switch") - tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptySet()) - } + private suspend fun handleBounceWithBackoff( + configsPeerKeyResolvedMap: List>> + ) { // Simplified param: no failureCount + val settings = appDataRepository.get().settings.get() + val pingIntervalMillis = settings.tunnelPingIntervalSeconds.toMillis() + configsPeerKeyResolvedMap.forEach { (config, peerMap) -> + val bounceCount = bounceCounts.value.getOrDefault(config.id, 0) + val exponent = bounceCount.toDouble() + val backoffDelay = + (pingIntervalMillis * 2.0.pow(exponent)).toLong().coerceAtMost(MAX_BACKOFF_MS) + val currentTime = System.currentTimeMillis() + val lastTime = lastBounceTimes.getOrDefault(config.id, 0L) + if (currentTime - lastTime >= backoffDelay) { + Timber.d( + "Bouncing tunnel ${config.name} after detecting failure, with bounce count $bounceCount and calculated backoff delay $backoffDelay ms" + ) + tunnelManager.bounceTunnel(config, Ping(peerMap)) + lastBounceTimes[config.id] = currentTime + bounceCounts.update { current -> + current.toMutableMap().apply { this[config.id] = (this[config.id] ?: 0) + 1 } } + } else { + Timber.d( + "Backoff in progress for tunnel ${config.name}, skipping bounce (required delay: $backoffDelay ms)" + ) } } + } - @OptIn(FlowPreview::class) - private fun startAutoTunnelJob() = - lifecycleScope.launch(ioDispatcher) { - Timber.i("Starting auto-tunnel network event watcher") - val settings = appDataRepository.get().settings.get() - - var reevaluationJob: Job? = null - - autoTunnelStateFlow.debounce(settings.debounceDelayMillis()).collect { watcherState -> - if (watcherState == defaultState) return@collect - reevaluationJob?.cancel() - handleAutoTunnelEvent(watcherState) - - // schedule one-time re-evaluation - reevaluationJob = launch { - delay(REEVALUATE_CHECK_DELAY) - if (watcherState != defaultState) { - Timber.d("Re-evaluating auto-tunnel state..") - handleAutoTunnelEvent(watcherState) - } - } + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private val debouncedConnectivityStateFlow: Flow by lazy { + appDataRepository + .get() + .settings + .flow + .map { it.debounceDelaySeconds.toMillis() } + .distinctUntilChanged() + .flatMapLatest { debounceMillis -> + networkMonitor.connectivityStateFlow.debounce(debounceMillis) } - } - - private suspend fun handleAutoTunnelEvent(watcherState: AutoTunnelState) { - Timber.i("Auto-tunnel settings: ${watcherState.settings.toAutoTunnelStateString()}") - Timber.i("Auto-tunnel network state: ${watcherState.networkState}") - when ( - val event = - watcherState.asAutoTunnelEvent().also { - Timber.i("Auto-tunnel event: ${it.javaClass.simpleName}") - } - ) { - is AutoTunnelEvent.Start -> - (event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let { - tunnelManager.startTunnel(it) - } - is AutoTunnelEvent.Stop -> tunnelManager.stopTunnel() - AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do") - } } companion object { - const val REEVALUATE_CHECK_DELAY = 5_000L + // try to keep this window short as it will interrupt manual overrides + const val REEVALUATE_CHECK_DELAY = 2_000L + const val MAX_BACKOFF_MS = 300_000L // 5 minutes } } 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 new file mode 100644 index 00000000..fa3f242c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt @@ -0,0 +1,20 @@ +package com.zaneschepke.wireguardautotunnel.core.service.autotunnel + +import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState +import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState +import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels +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 ActiveTunnelsChange(val activeTunnels: Map) : StateChange() + + data class MonitoringChange(val pingStates: Map?>) : + StateChange() +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt index 7b9a6c89..c9bbefc5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt @@ -2,37 +2,42 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.wireguard.android.backend.Tunnel import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.di.ApplicationScope -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository +import com.zaneschepke.wireguardautotunnel.domain.state.PingState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState import java.util.concurrent.ConcurrentHashMap -import kotlin.concurrent.thread +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.amnezia.awg.crypto.Key import timber.log.Timber abstract class BaseTunnel( - @ApplicationScope private val applicationScope: CoroutineScope, + private val applicationScope: CoroutineScope, private val appDataRepository: AppDataRepository, private val serviceManager: ServiceManager, ) : TunnelProvider { - private val _errorEvents = - MutableSharedFlow>(replay = 0, extraBufferCapacity = 1) + private val _errorEvents = MutableSharedFlow>() override val errorEvents = _errorEvents.asSharedFlow() + private val _messageEvents = MutableSharedFlow>() + override val messageEvents = _messageEvents.asSharedFlow() + private val activeTuns = MutableStateFlow>(emptyMap()) - private val tunThreads = ConcurrentHashMap() + private val tunJobs = ConcurrentHashMap() override val activeTunnels = activeTuns.asStateFlow() private val tunMutex = Mutex() @@ -49,32 +54,43 @@ abstract class BaseTunnel( return serviceManager.hasVpnPermission() } - protected suspend fun updateTunnelStatus( + override suspend fun updateTunnelStatus( tunnelConf: TunnelConf, - status: TunnelStatus? = null, - stats: TunnelStatistics? = null, + status: TunnelStatus?, + stats: TunnelStatistics?, + pingStates: Map?, + handshakeSuccessLogs: Boolean?, ) { tunStatusMutex.withLock { activeTuns.update { currentTuns -> val originalConf = currentTuns.getKeyById(tunnelConf.id) ?: tunnelConf val existingState = currentTuns.getValueById(tunnelConf.id) ?: TunnelState() - val newState = status ?: existingState.status - if (newState == TunnelStatus.Down) { + val newStatus = status ?: existingState.status + if (newStatus == TunnelStatus.Down) { Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN") - cleanUpTunThread(tunnelConf) + cleanUpTunJob(tunnelConf) currentTuns - originalConf - } else if (existingState.status == newState && stats == null) { - Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState") + } else if ( + existingState.status == newStatus && + stats == null && + pingStates == null && + handshakeSuccessLogs == null + ) { + Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newStatus") currentTuns } else { val updated = existingState.copy( - status = newState, + status = newStatus, statistics = stats ?: existingState.statistics, + pingStates = pingStates ?: existingState.pingStates, + handshakeSuccessLogs = + handshakeSuccessLogs ?: existingState.handshakeSuccessLogs, ) currentTuns + (originalConf to updated) } } + handleServiceStateOnChange() } } @@ -104,44 +120,100 @@ abstract class BaseTunnel( } } - override suspend fun updateTunnelStatistics(tunnel: TunnelConf) { - val stats = getStatistics(tunnel) - updateTunnelStatus(tunnel, null, stats) - } - override suspend fun startTunnel(tunnelConf: TunnelConf) { - if (activeTuns.exists(tunnelConf.id) || tunThreads.containsKey(tunnelConf.id)) return + if (activeTuns.exists(tunnelConf.id) || tunJobs.containsKey(tunnelConf.id)) + return Timber.w("Tunnel is already running ${tunnelConf.name}") + // For userspace, we need to make sure all previous tunnels are down if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels() tunMutex.withLock { - tunThreads[tunnelConf.id] = thread { - try { - runBlocking { + val job = + applicationScope.launch { + try { Timber.d("Starting tunnel ${tunnelConf.id}...") startTunnelInner(tunnelConf) Timber.d("Started complete for tunnel ${tunnelConf.name}...") + // catch cancellation that could occur before and during startTunnelInner + // and trigger at that suspend point + } catch (e: CancellationException) { + Timber.w( + "Tunnel start has been cancelled as ${tunnelConf.name} failed to start" + ) } - } catch (e: InterruptedException) { - Timber.w( - "Tunnel start has been interrupted as ${tunnelConf.name} failed to start" - ) } + tunJobs[tunnelConf.id] = job + job.invokeOnCompletion { + tunJobs.remove(tunnelConf.id) + Timber.d("Start job completed for tunnel ${tunnelConf.id}") } } } private suspend fun startTunnelInner(tunnelConf: TunnelConf) { configureTunnelCallbacks(tunnelConf) - Timber.d("Starting backend for tunnel ${tunnelConf.id}...") - try { - startBackend(tunnelConf) - updateTunnelStatus(tunnelConf, TunnelStatus.Up) - Timber.d("Started for tun ${tunnelConf.id}...") - saveTunnelActiveState(tunnelConf, true) - serviceManager.startTunnelForegroundService() - } catch (e: BackendError) { - Timber.e(e, "Failed to start backend for ${tunnelConf.name}") - _errorEvents.emit(tunnelConf to e) - updateTunnelStatus(tunnelConf, TunnelStatus.Down) + Timber.d("Starting backend for tunnel ${tunnelConf.id}...") + + var currentConf = tunnelConf + var restoreAttempted = false + var originalError: BackendError? = null + + while (true) { + try { + startBackend(currentConf) + updateTunnelStatus(currentConf, TunnelStatus.Up) + Timber.d("Started for tun ${currentConf.id}...") + saveTunnelActiveState(currentConf, true) + serviceManager.startTunnelForegroundService() + if (restoreAttempted) + _messageEvents.emit(tunnelConf to BackendMessage.BounceRecovery) + if (bouncingTunnelIds[currentConf.id] is TunnelStatus.StopReason.Ping) { + _messageEvents.emit(tunnelConf to BackendMessage.BounceSuccess) + } + return // Success, return + } catch (e: BackendError) { + originalError = originalError ?: e + val bounceReason = bouncingTunnelIds[currentConf.id] + if (!restoreAttempted && bounceReason is TunnelStatus.StopReason.Ping) { + Timber.i( + "Attempting to recover bounce failure with previously resolved endpoints for ${currentConf.name}" + ) + try { + val previouslyResolved = bounceReason.previouslyResolvedEndpoints + val configProxy = ConfigProxy.from(currentConf.toAmConfig()) + val updatedConfigProxy = + configProxy.copy( + peers = + configProxy.peers.map { + it.copy( + endpoint = + previouslyResolved[it.publicKey] ?: it.endpoint + ) + } + ) + val (wg, amnezia) = updatedConfigProxy.buildConfigs() + currentConf = + currentConf.copyWithCallback( + amQuick = amnezia.toAwgQuickString(true), + wgQuick = wg.toWgQuickString(true), + ) + bouncingTunnelIds.remove(currentConf.id) + restoreAttempted = true + continue // Retry + } catch (e: Exception) { + Timber.e( + e, + "Failed to update config with resolved endpoints for ${currentConf.name}", + ) + // Fall through to failure (will emit BounceFailed since + // retryAttempted=true) + } + } + Timber.e(e, "Failed to start backend for ${currentConf.name}") + val emitError = + if (restoreAttempted) BackendError.BounceFailed(originalError) else e + _errorEvents.emit(currentConf to emitError) + updateTunnelStatus(currentConf, TunnelStatus.Down) + return + } } } @@ -174,30 +246,23 @@ abstract class BaseTunnel( } private fun handleServiceStateOnChange() { - if (activeTuns.value.isEmpty() && bouncingTunnelIds.isEmpty()) - serviceManager.stopTunnelForegroundService() + if (activeTuns.value.isEmpty()) serviceManager.stopTunnelForegroundService() } private suspend fun handleStuckStartingTunnelShutdown(tunnel: TunnelConf) { - Timber.d("Stuck in starting state so shutting down tunnel thread for tunnel ${tunnel.name}") + Timber.d("Stuck in starting state so cancelling job for tunnel ${tunnel.name}") try { - tunThreads[tunnel.id]?.let { - if (it.state != Thread.State.TERMINATED) { - it.interrupt() - } else { - Timber.d("Thread already terminated") - } - } + tunJobs[tunnel.id]?.cancel() ?: Timber.d("No job found for ${tunnel.name}") } catch (e: Exception) { - Timber.e(e, "Failed to stop tunnel thread for ${tunnel.name}") + Timber.e(e, "Failed to cancel job for ${tunnel.name}") } finally { updateTunnelStatus(tunnel, TunnelStatus.Down) } } - private fun cleanUpTunThread(tunnel: TunnelConf) { - Timber.d("Removing thread for ${tunnel.name}") - tunThreads -= tunnel.id + private fun cleanUpTunJob(tunnel: TunnelConf) { + Timber.d("Removing job for ${tunnel.name}") + tunJobs -= tunnel.id } private fun removeActiveTunnel(tunnelConf: TunnelConf) { @@ -210,16 +275,10 @@ abstract class BaseTunnel( "Bounce tunnel ${tunnelConf.name} for reason: $reason, current bouncing: ${bouncingTunnelIds.size}" ) bouncingTunnelIds[tunnelConf.id] = reason - try { + runCatching { stopTunnel(tunnelConf, reason) delay(BOUNCE_DELAY) startTunnel(tunnelConf) - } finally { - bouncingTunnelIds.remove(tunnelConf.id) - handleServiceStateOnChange() - Timber.d( - "Cleared bounce state for ${tunnelConf.name}, remaining: ${bouncingTunnelIds.size}" - ) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt index b5971591..184841ba 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt @@ -5,9 +5,9 @@ import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.Tunnel import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.di.ApplicationScope -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics @@ -42,7 +42,11 @@ constructor( updateTunnelStatus(tunnel, TunnelStatus.Starting) backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig()) } catch (e: BackendException) { + Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}") throw e.toBackendError() + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}") + throw BackendError.Config } } 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 1260a2d1..9b185466 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 @@ -1,35 +1,32 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager -import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification -import com.zaneschepke.wireguardautotunnel.di.ApplicationScope -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.di.Kernel -import com.zaneschepke.wireguardautotunnel.di.Userspace -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository +import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics -import com.zaneschepke.wireguardautotunnel.util.StringValue import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus +import org.amnezia.awg.crypto.Key @OptIn(ExperimentalCoroutinesApi::class) class TunnelManager @Inject constructor( - @Kernel private val kernelTunnel: TunnelProvider, - @Userspace private val userspaceTunnel: TunnelProvider, + private val kernelTunnel: TunnelProvider, + private val userspaceTunnel: TunnelProvider, private val appDataRepository: AppDataRepository, - @ApplicationScope private val applicationScope: CoroutineScope, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val notificationManager: NotificationManager, + applicationScope: CoroutineScope, + ioDispatcher: CoroutineDispatcher, ) : TunnelProvider { @OptIn(ExperimentalCoroutinesApi::class) @@ -45,63 +42,29 @@ constructor( initialValue = userspaceTunnel, ) - @OptIn(ExperimentalCoroutinesApi::class) - override val activeTunnels = - appDataRepository.settings.flow - .filterNotNull() - .flatMapLatest { settings -> - if (settings.isKernelEnabled) { - kernelTunnel.activeTunnels - } else { - userspaceTunnel.activeTunnels - } - } - .stateIn( - scope = applicationScope.plus(ioDispatcher), - started = SharingStarted.Eagerly, - initialValue = emptyMap(), - ) + override val activeTunnels: StateFlow> = + tunnelProviderFlow.value.activeTunnels @OptIn(ExperimentalCoroutinesApi::class) override val errorEvents: SharedFlow> = - combine( - tunnelProviderFlow.flatMapLatest { it.errorEvents }, - WireGuardAutoTunnel.uiActive, - ) { errorEvent, isEnabled -> - if (isEnabled) errorEvent else null - } - .filterNotNull() + tunnelProviderFlow + .flatMapLatest { it.errorEvents } .shareIn( scope = applicationScope.plus(ioDispatcher), - started = SharingStarted.WhileSubscribed(5_000), + started = SharingStarted.Eagerly, replay = 0, ) - // observe tunnel errors and launch notifications if ui is inactive - init { - applicationScope.launch(ioDispatcher) { - tunnelProviderFlow - .flatMapLatest { it.errorEvents } - .collect { (tunnelConf, error) -> - if (!WireGuardAutoTunnel.uiActive.value) { - val notification = - notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = StringValue.DynamicString(tunnelConf.name), - description = - StringValue.StringResource( - R.string.tunnel_error_template, - error.toStringRes(), - ), - ) - notificationManager.show( - NotificationManager.TUNNEL_STATUS_NOTIFICATION_ID, - notification, - ) - } - } - } - } + @OptIn(ExperimentalCoroutinesApi::class) + override val messageEvents: SharedFlow> = + tunnelProviderFlow + .flatMapLatest { it.messageEvents } + .filterNotNull() + .shareIn( + scope = applicationScope.plus(ioDispatcher), + started = SharingStarted.Eagerly, + replay = 0, + ) override val bouncingTunnelIds: ConcurrentHashMap = tunnelProviderFlow.value.bouncingTunnelIds @@ -110,8 +73,8 @@ constructor( return userspaceTunnel.hasVpnPermission() } - override suspend fun updateTunnelStatistics(tunnel: TunnelConf) { - tunnelProviderFlow.value.updateTunnelStatistics(tunnel) + override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { + return tunnelProviderFlow.value.getStatistics(tunnelConf) } override suspend fun startTunnel(tunnelConf: TunnelConf) { @@ -138,24 +101,35 @@ constructor( return tunnelProviderFlow.value.runningTunnelNames() } - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { - return tunnelProviderFlow.value.getStatistics(tunnelConf) + override suspend fun updateTunnelStatus( + tunnelConf: TunnelConf, + status: TunnelStatus?, + stats: TunnelStatistics?, + pingStates: Map?, + handshakeSuccessLogs: Boolean?, + ) { + tunnelProviderFlow.value.updateTunnelStatus( + tunnelConf, + status, + stats, + pingStates, + handshakeSuccessLogs, + ) } - fun restorePreviousState(): Job = - applicationScope.launch(ioDispatcher) { - val settings = appDataRepository.settings.get() - if (settings.isRestoreOnBootEnabled) { - val previouslyActiveTuns = appDataRepository.tunnels.getActive() - val tunsToStart = - previouslyActiveTuns.filterNot { tun -> - activeTunnels.value.any { tun.id == it.key.id } - } - if (settings.isKernelEnabled) { - return@launch tunsToStart.forEach { startTunnel(it) } - } else { - tunsToStart.firstOrNull()?.let { startTunnel(it) } + suspend fun restorePreviousState() { + val settings = appDataRepository.settings.get() + if (settings.isRestoreOnBootEnabled) { + val previouslyActiveTuns = appDataRepository.tunnels.getActive() + val tunsToStart = + previouslyActiveTuns.filterNot { tun -> + activeTunnels.value.any { tun.id == it.key.id } } + if (settings.isKernelEnabled) { + return tunsToStart.forEach { startTunnel(it) } + } else { + tunsToStart.firstOrNull()?.let { startTunnel(it) } } } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelMonitor.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelMonitor.kt new file mode 100644 index 00000000..9eae1854 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelMonitor.kt @@ -0,0 +1,266 @@ +package com.zaneschepke.wireguardautotunnel.core.tunnel + +import com.zaneschepke.logcatter.LogReader +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository +import com.zaneschepke.wireguardautotunnel.domain.state.FailureReason +import com.zaneschepke.wireguardautotunnel.domain.state.PingState +import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis +import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils +import dagger.hilt.android.scopes.ServiceScoped +import io.ktor.util.collections.* +import javax.inject.Inject +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.amnezia.awg.crypto.Key +import timber.log.Timber + +@ServiceScoped +class TunnelMonitor +@Inject +constructor( + private val appDataRepository: AppDataRepository, + private val tunnelManager: TunnelManager, + private val networkMonitor: NetworkMonitor, + private val networkUtils: NetworkUtils, + private val logReader: LogReader, +) { + + @OptIn(FlowPreview::class) + suspend fun startMonitoring(tunnelConf: TunnelConf, withLogs: Boolean): Job = coroutineScope { + launch { + launch { startTunnelConfChangesJob(tunnelConf) } + launch { startPingMonitor(tunnelConf) } + launch { startWgStatsPoll(tunnelConf) } + if (withLogs) launch { startLogsMonitor(tunnelConf) } + } + } + + private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) { + appDataRepository.tunnels.flow + .map { storedTunnels -> storedTunnels.firstOrNull { it.id == tunnelConf.id } } + .filterNotNull() + .distinctUntilChanged { old, new -> old == new } + .collect { storedTunnel -> + if (tunnelConf != storedTunnel) { + Timber.d("Config changed for ${storedTunnel.tunName}, bouncing") + withContext(NonCancellable) { + tunnelManager.bounceTunnel( + storedTunnel, + TunnelStatus.StopReason.ConfigChanged, + ) + } + } + } + } + + private suspend fun startLogsMonitor(tunnelConf: TunnelConf) { + logReader.liveLogs.collect { log -> + val healthLogs = + when { + log.message.contains(HANDSHAKE_RESPONSE_TEXT, true) || + log.message.contains(KEEPALIVE_RESPONSE_TEXT, true) -> true + log.message.contains(HANDSHAKE_INIT_FAILED_TEXT, true) || + log.message.contains(HANDSHAKE_NOT_COMPLETED_TEXT) || + log.message.contains(DATA_PACKET_FAILED_TEXT) -> false + + else -> null + } + healthLogs?.let { healthy -> + tunnelManager.updateTunnelStatus(tunnelConf, null, null, null, healthy) + } + } + } + + private suspend fun startPingMonitor(tunnelConf: TunnelConf) = coroutineScope { + val pingStatsFlow = MutableStateFlow>(emptyMap()) + + val tunStateFlow = + tunnelManager.activeTunnels.mapNotNull { it.getValueById(tunnelConf.id) }.stateIn(this) + + val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this) + + val isNetworkConnected = connectivityStateFlow.map { it.hasConnectivity() }.stateIn(this) + + data class NetworkChangeKey( + val ethernetConnected: Boolean, + val wifiConnected: Boolean, + val cellularConnected: Boolean, + val wifiSsid: String?, + ) + + connectivityStateFlow + .map { + NetworkChangeKey( + ethernetConnected = it.ethernetConnected, + wifiConnected = it.wifiState.connected, + cellularConnected = it.cellularConnected, + wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null, + ) + } + .distinctUntilChanged() + .stateIn(this) + + appDataRepository.settings.flow + .distinctUntilChanged { old, new -> + old.isPingEnabled == new.isPingEnabled && + old.tunnelPingIntervalSeconds == new.tunnelPingIntervalSeconds && + old.tunnelPingAttempts == new.tunnelPingAttempts && + old.tunnelPingTimeoutSeconds == new.tunnelPingTimeoutSeconds + } + .collectLatest { settings -> + if (!settings.isPingEnabled) return@collectLatest + + Timber.d("Starting pinger for ${tunnelConf.tunName} with settings") + + val config = tunnelConf.toAmConfig() + + val pingablePeers = config.peers.filter { it.allowedIps.isNotEmpty() } + if (pingablePeers.isEmpty()) return@collectLatest + + suspend fun performPing() { + val updates = ConcurrentMap() + + pingablePeers.forEach { peer -> + val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState() + + val allowedIpStr = peer.allowedIps.firstOrNull()?.toString() + if (allowedIpStr == null) { + updates[peer.publicKey] = + previousState.copy( + isReachable = false, + failureReason = FailureReason.NoResolvedEndpoint, + lastPingAttemptMillis = System.currentTimeMillis(), + ) + return@forEach + } + + val host = + { + val parts = allowedIpStr.split("/") + val internalIp = if (parts.size == 2) parts[0] else allowedIpStr + + val prefix = + if (parts.size == 2) parts[1].toIntOrNull() ?: 32 else 32 + if (prefix <= 1) { + tunnelConf.pingTarget ?: CLOUDFLARE_IPV4_IP + } else { + internalIp.removeSurrounding("[", "]") + } + } + .invoke() + + val attemptTime = System.currentTimeMillis() + runCatching { + val pingStats = + settings.tunnelPingTimeoutSeconds?.let { + networkUtils.pingWithStats( + host, + settings.tunnelPingAttempts, + it.toMillis(), + ) + } + ?: networkUtils.pingWithStats( + host, + settings.tunnelPingAttempts, + ) + + updates[peer.publicKey] = + previousState.copy( + transmitted = pingStats.transmitted, + received = pingStats.received, + packetLoss = pingStats.packetLoss, + rttMin = pingStats.rttMin, + rttMax = pingStats.rttMax, + rttAvg = pingStats.rttAvg, + rttStddev = pingStats.rttStddev, + isReachable = pingStats.isReachable, + failureReason = + if (pingStats.isReachable) null + else FailureReason.PingFailed, + lastSuccessfulPingMillis = + pingStats.lastSuccessfulPingMillis + ?: previousState.lastSuccessfulPingMillis, + pingTarget = host, + lastPingAttemptMillis = attemptTime, + ) + Timber.d( + "Ping completed for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats" + ) + } + .onFailure { + Timber.e( + it, + "Ping failed for peer ${peer.publicKey} in ${tunnelConf.tunName} to host $host", + ) + updates[peer.publicKey] = + previousState.copy( + isReachable = false, + failureReason = FailureReason.PingFailed, + pingTarget = host, + lastPingAttemptMillis = attemptTime, + ) + } + } + + if (updates.isNotEmpty()) { + pingStatsFlow.update { updates } + tunnelManager.updateTunnelStatus(tunnelConf, null, null, updates) + } + } + + // Wait for the tunnel to be fully active + tunStateFlow.filter { state -> state.status == TunnelStatus.Up }.first() + + // small delay to make sure tunnel is fully up before we actively monitor + delay(3_000L) + + while (isActive) { + if (isNetworkConnected.value) { + performPing() + } else { + pingStatsFlow.update { current -> + current.mapValues { entry -> + entry.value.copy( + isReachable = false, + failureReason = FailureReason.NoConnectivity, + lastPingAttemptMillis = System.currentTimeMillis(), + ) + } + } + tunnelManager.updateTunnelStatus( + tunnelConf, + null, + null, + pingStatsFlow.value, + ) + } + delay(settings.tunnelPingIntervalSeconds.toMillis()) + } + } + } + + private suspend fun startWgStatsPoll(tunnelConf: TunnelConf) = coroutineScope { + while (isActive) { + val stats = tunnelManager.getStatistics(tunnelConf) + tunnelManager.updateTunnelStatus(tunnelConf, null, stats, null) + delay(STATS_DELAY) + } + } + + companion object { + const val CLOUDFLARE_IPV6_IP = "2606:4700:4700::1111" + const val CLOUDFLARE_IPV4_IP = "1.1.1.1" + + const val STATS_DELAY = 1_000L + + const val KEEPALIVE_RESPONSE_TEXT = "Receiving keepalive packet" + const val HANDSHAKE_RESPONSE_TEXT = "Received handshake response" + const val HANDSHAKE_INIT_FAILED_TEXT = "Failed to send handshake initiation: write udp" + const val DATA_PACKET_FAILED_TEXT = "Failed to send data packets" + const val HANDSHAKE_NOT_COMPLETED_TEXT = + "Handshake did not complete after 5 seconds, retrying" + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt index 88cd5e37..88572c85 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt @@ -1,14 +1,17 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError +import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.state.PingState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import org.amnezia.awg.crypto.Key interface TunnelProvider { /** Starts the specified tunnel configuration. */ @@ -23,19 +26,19 @@ interface TunnelProvider { */ suspend fun stopTunnel( tunnelConf: TunnelConf? = null, - reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER, + reason: TunnelStatus.StopReason = TunnelStatus.StopReason.User, ) /** * Bounces (stops and restarts) the specified tunnel. * * @param tunnelConf The tunnel to bounce. - * @param reason The reason for bouncing, defaults to USER for manual actions. Callers should - * override with specific reasons (e.g., PING, CONFIG_CHANGED) when applicable. + * @param reason The reason for bouncing, defaults to User for manual actions. Callers should + * override with specific reasons (e.g., Ping, ConfigChanged) when applicable. */ suspend fun bounceTunnel( tunnelConf: TunnelConf, - reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER, + reason: TunnelStatus.StopReason = TunnelStatus.StopReason.User, ) fun setBackendState(backendState: BackendState, allowedIps: Collection) @@ -50,9 +53,17 @@ interface TunnelProvider { val errorEvents: SharedFlow> + val messageEvents: SharedFlow> + val bouncingTunnelIds: ConcurrentHashMap fun hasVpnPermission(): Boolean - suspend fun updateTunnelStatistics(tunnel: TunnelConf) + suspend fun updateTunnelStatus( + tunnelConf: TunnelConf, + status: TunnelStatus? = null, + stats: TunnelStatistics? = null, + pingStates: Map? = null, + handshakeSuccessLogs: Boolean? = null, + ) } 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 27939643..70b157a2 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 @@ -1,9 +1,9 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics @@ -23,7 +23,7 @@ import timber.log.Timber class UserspaceTunnel @Inject constructor( - @ApplicationScope private val applicationScope: CoroutineScope, + applicationScope: CoroutineScope, val serviceManager: ServiceManager, val appDataRepository: AppDataRepository, private val backend: Backend, @@ -40,6 +40,9 @@ constructor( } catch (e: BackendException) { Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}") throw e.toBackendError() + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}") + throw BackendError.Config } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt index d8a456a7..08dd2690 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/worker/ServiceWorker.kt @@ -2,11 +2,7 @@ package com.zaneschepke.wireguardautotunnel.core.worker import android.content.Context import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters +import androidx.work.* import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.di.IoDispatcher 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 0517f9a3..896b418a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.data -import androidx.room.AutoMigration -import androidx.room.Database -import androidx.room.DeleteColumn -import androidx.room.RoomDatabase -import androidx.room.TypeConverters +import androidx.room.* import androidx.room.migration.AutoMigrationSpec import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao @@ -13,7 +9,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig @Database( entities = [Settings::class, TunnelConfig::class], - version = 18, + version = 19, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -33,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17, spec = WifiDetectionMigration::class), AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19, spec = PingMigration::class), ], exportSchema = true, ) @@ -52,3 +49,22 @@ class RemoveTunnelPauseMigration : AutoMigrationSpec @DeleteColumn(tableName = "Settings", columnName = "is_wifi_by_shell_enabled") class WifiDetectionMigration : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "TunnelConfig", columnName = "ping_interval"), + DeleteColumn(tableName = "TunnelConfig", columnName = "ping_cooldown"), + DeleteColumn(tableName = "Settings", columnName = "split_tunnel_apps"), +) +@RenameColumn.Entries( + RenameColumn( + tableName = "TunnelConfig", + fromColumnName = "is_ping_enabled", + toColumnName = "restart_on_ping_failure", + ), + RenameColumn( + tableName = "TunnelConfig", + fromColumnName = "ping_ip", + toColumnName = "ping_target", + ), +) +class PingMigration : AutoMigrationSpec 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 3edf5250..92fca1b1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt @@ -31,6 +31,7 @@ class DataStoreManager( val theme = stringPreferencesKey("THEME") val isRemoteControlEnabled = booleanPreferencesKey("IS_REMOTE_CONTROL_ENABLED") val remoteKey = stringPreferencesKey("REMOTE_KEY") + val showDetailedPingStats = booleanPreferencesKey("SHOW_DETAILED_PING_STATS") } // preferences 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 4ae2e567..ede4c5f7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt @@ -6,19 +6,19 @@ import kotlinx.serialization.json.Json class DatabaseConverters { @TypeConverter - fun listToString(value: MutableList): String { + fun listToString(value: List): String { return Json.encodeToString(value) } @TypeConverter - fun stringToList(value: String): MutableList { + fun stringToList(value: String): List { if (value.isBlank() || value.isEmpty()) return mutableListOf() return try { - Json.decodeFromString>(value) + Json.decodeFromString>(value) } catch (e: Exception) { val list = value.split(",").toMutableList() val json = listToString(list) - Json.decodeFromString>(json) + Json.decodeFromString>(json) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/SettingsDao.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/SettingsDao.kt index fe270ffd..7924bd5a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/SettingsDao.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/dao/SettingsDao.kt @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.data.dao -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query +import androidx.room.* import com.zaneschepke.wireguardautotunnel.data.entity.Settings import kotlinx.coroutines.flow.Flow 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 7d7b1048..0ed20861 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 @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.data.dao -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query +import androidx.room.* import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralState.kt index 2e6368a0..49be5b9b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralState.kt @@ -9,6 +9,7 @@ data class GeneralState( val expandedTunnelIds: List = emptyList(), val isLocalLogsEnabled: Boolean = IS_LOGS_ENABLED_DEFAULT, val isRemoteControlEnabled: Boolean = IS_REMOTE_CONTROL_ENABLED, + val showDetailedPingStats: Boolean = SHOW_DETAILED_PING_STATS_DEFAULT, val remoteKey: String? = null, val locale: String? = null, val theme: Theme = Theme.AUTOMATIC, @@ -20,5 +21,6 @@ data class GeneralState( const val PIN_LOCK_ENABLED_DEFAULT = false const val IS_LOGS_ENABLED_DEFAULT = false const val IS_REMOTE_CONTROL_ENABLED = false + const val SHOW_DETAILED_PING_STATS_DEFAULT = false } } 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 2e7d14fb..f6770209 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,8 +10,7 @@ data class Settings( @ColumnInfo(name = "is_tunnel_enabled") val isAutoTunnelEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") val isTunnelOnMobileDataEnabled: Boolean = false, - @ColumnInfo(name = "trusted_network_ssids") - val trustedNetworkSSIDs: MutableList = mutableListOf(), + @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") val isTunnelOnEthernetEnabled: Boolean = false, @@ -45,12 +44,15 @@ data class Settings( val isDisableKillSwitchOnTrustedEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_unsecure_enabled", defaultValue = "false") val isTunnelOnUnsecureEnabled: Boolean = false, - @ColumnInfo(name = "split_tunnel_apps", defaultValue = "") - val splitTunnelApps: MutableList = mutableListOf(), @ColumnInfo(name = "wifi_detection_method", defaultValue = "0") val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), + @ColumnInfo(name = "is_ping_monitoring_enabled", defaultValue = "true") + val isPingMonitoringEnabled: Boolean = true, + @ColumnInfo(name = "tunnel_ping_interval_sec", defaultValue = "30") + val tunnelPingIntervalSeconds: Int = 30, + @ColumnInfo(name = "tunnel_ping_attempts", defaultValue = "3") val tunnelPingAttempts: Int = 3, + @ColumnInfo(name = "tunnel_ping_timeout_sec") val tunnelPingTimeoutSeconds: Int? = null, ) { - enum class WifiDetectionMethod(val value: Int) { DEFAULT(0), LEGACY(1), 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 e699d49c..52abb624 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,23 +11,23 @@ data class TunnelConfig( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "wg_quick") val wgQuick: String, @ColumnInfo(name = "tunnel_networks", defaultValue = "") - val tunnelNetworks: MutableList = mutableListOf(), + val tunnelNetworks: List = listOf(), @ColumnInfo(name = "is_mobile_data_tunnel", defaultValue = "false") val isMobileDataTunnel: Boolean = false, @ColumnInfo(name = "is_primary_tunnel", defaultValue = "false") val isPrimaryTunnel: Boolean = false, @ColumnInfo(name = "am_quick", defaultValue = "") val amQuick: String = AM_QUICK_DEFAULT, @ColumnInfo(name = "is_Active", defaultValue = "false") val isActive: Boolean = false, - @ColumnInfo(name = "is_ping_enabled", defaultValue = "false") - val isPingEnabled: Boolean = false, - @ColumnInfo(name = "ping_interval", defaultValue = "null") val pingInterval: Long? = null, - @ColumnInfo(name = "ping_cooldown", defaultValue = "null") val pingCooldown: Long? = null, - @ColumnInfo(name = "ping_ip", defaultValue = "null") var pingIp: String? = null, + @ColumnInfo(name = "restart_on_ping_failure", defaultValue = "false") + val restartOnPingFailure: Boolean = false, + @ColumnInfo(name = "ping_target", defaultValue = "null") var pingTarget: String? = null, @ColumnInfo(name = "is_ethernet_tunnel", defaultValue = "false") val isEthernetTunnel: Boolean = false, @ColumnInfo(name = "is_ipv4_preferred", defaultValue = "true") val isIpv4Preferred: Boolean = true, @ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0, + @ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]") + val autoTunnelApps: List = listOf(), ) { companion object { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GeneralStateMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GeneralStateMapper.kt index 422d5fbc..ffabb86c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GeneralStateMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GeneralStateMapper.kt @@ -13,6 +13,7 @@ object GeneralStateMapper { expandedTunnelIds, isLocalLogsEnabled, isRemoteControlEnabled, + showDetailedPingStats, remoteKey, locale, theme, @@ -28,6 +29,7 @@ object GeneralStateMapper { expandedTunnelIds, isLocalLogsEnabled, isRemoteControlEnabled, + showDetailedPingStats, remoteKey, locale, theme, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GitHubReleaseMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GitHubReleaseMapper.kt index 33674735..b65924e1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GitHubReleaseMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/GitHubReleaseMapper.kt @@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.data.mapper import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate -import kotlin.collections.firstOrNull object GitHubReleaseMapper { fun toAppUpdate(gitHubRelease: GitHubRelease, newVersion: String): AppUpdate { 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 5ae4496b..531a9695 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 @@ -28,11 +28,13 @@ object SettingsMapper { debounceDelaySeconds = settings.debounceDelaySeconds, isDisableKillSwitchOnTrustedEnabled = settings.isDisableKillSwitchOnTrustedEnabled, isTunnelOnUnsecureEnabled = settings.isTunnelOnUnsecureEnabled, - splitTunnelApps = settings.splitTunnelApps, wifiDetectionMethod = AndroidNetworkMonitor.WifiDetectionMethod.fromValue( settings.wifiDetectionMethod.value ), + tunnelPingIntervalSeconds = settings.tunnelPingIntervalSeconds, + tunnelPingAttempts = settings.tunnelPingAttempts, + tunnelPingTimeoutSeconds = settings.tunnelPingTimeoutSeconds, ) } @@ -41,7 +43,7 @@ object SettingsMapper { id = appSettings.id, isAutoTunnelEnabled = appSettings.isAutoTunnelEnabled, isTunnelOnMobileDataEnabled = appSettings.isTunnelOnMobileDataEnabled, - trustedNetworkSSIDs = appSettings.trustedNetworkSSIDs.toMutableList(), + trustedNetworkSSIDs = appSettings.trustedNetworkSSIDs, isAlwaysOnVpnEnabled = appSettings.isAlwaysOnVpnEnabled, isTunnelOnEthernetEnabled = appSettings.isTunnelOnEthernetEnabled, isShortcutsEnabled = appSettings.isShortcutsEnabled, @@ -59,9 +61,11 @@ object SettingsMapper { debounceDelaySeconds = appSettings.debounceDelaySeconds, isDisableKillSwitchOnTrustedEnabled = appSettings.isDisableKillSwitchOnTrustedEnabled, isTunnelOnUnsecureEnabled = appSettings.isTunnelOnUnsecureEnabled, - splitTunnelApps = appSettings.splitTunnelApps.toMutableList(), wifiDetectionMethod = Settings.WifiDetectionMethod.fromValue(appSettings.wifiDetectionMethod.value), + tunnelPingIntervalSeconds = appSettings.tunnelPingIntervalSeconds, + tunnelPingAttempts = appSettings.tunnelPingAttempts, + tunnelPingTimeoutSeconds = appSettings.tunnelPingTimeoutSeconds, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt index e9a32706..e0d5913b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/TunnelConfigMapper.kt @@ -15,10 +15,8 @@ object TunnelConfigMapper { isPrimaryTunnel, amQuick, isActive, - isPingEnabled, - pingInterval, - pingCooldown, - pingIp, + pingTarget, + restartOnPingFailure, isEthernetTunnel, isIpv4Preferred, position, @@ -32,15 +30,13 @@ object TunnelConfigMapper { id, tunName, wgQuick, - tunnelNetworks.toMutableList(), + tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel, amQuick, isActive, - isPingEnabled, - pingInterval, - pingCooldown, - pingIp, + restartOnPingFailure, + pingTarget, isEthernetTunnel, isIpv4Preferred, position, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt index 372c8f64..e1606108 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorClient.kt @@ -1,10 +1,10 @@ package com.zaneschepke.wireguardautotunnel.data.network -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json object KtorClient { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt index f9174dcc..cc326fc1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/network/KtorGitHubApi.kt @@ -1,11 +1,11 @@ package com.zaneschepke.wireguardautotunnel.data.network import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.http.* class KtorGitHubApi(private val client: HttpClient) : GitHubApi { override suspend fun getLatestRelease(owner: String, repo: String): Result { 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 e93798f9..55f64c20 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 @@ -119,6 +119,15 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager return dataStoreManager.getFromStore(DataStoreManager.remoteKey) } + override suspend fun setShowDetailedPingStats(showDetailedPing: Boolean) { + dataStoreManager.saveToDataStore(DataStoreManager.showDetailedPingStats, showDetailedPing) + } + + override suspend fun getShowDetailedPing(): Boolean { + return dataStoreManager.getFromStore(DataStoreManager.showDetailedPingStats) + ?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT + } + override val flow: Flow = dataStoreManager.preferencesFlow .map { prefs -> @@ -144,6 +153,9 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager isRemoteControlEnabled = pref[DataStoreManager.isRemoteControlEnabled] ?: GeneralState.IS_REMOTE_CONTROL_ENABLED, + showDetailedPingStats = + pref[DataStoreManager.showDetailedPingStats] + ?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT, remoteKey = pref[DataStoreManager.remoteKey], locale = pref[DataStoreManager.locale], theme = getTheme(), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/GitHubUpdateRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/GitHubUpdateRepository.kt index 0e3738a7..f0e68cd6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/GitHubUpdateRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/GitHubUpdateRepository.kt @@ -9,13 +9,11 @@ import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.contentLength -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readAvailable +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.* import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext 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 392c8dab..9f28c4e8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/AppModule.kt @@ -4,9 +4,12 @@ import android.content.Context import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogcatReader import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager +import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.shortcut.DynamicShortcutManager import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,4 +50,19 @@ class AppModule { ): ShortcutManager { return DynamicShortcutManager(context, ioDispatcher) } + + @Singleton + @Provides + fun provideNetworkUtils(@IoDispatcher ioDispatcher: CoroutineDispatcher): NetworkUtils { + return NetworkUtils(ioDispatcher) + } + + @Singleton + @Provides + fun provideNotificationMonitor( + tunnelManager: TunnelManager, + notificationManager: NotificationManager, + ): NotificationMonitor { + return NotificationMonitor(tunnelManager, notificationManager) + } } 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 3bed0f0b..5f9c5c6e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/RepositoryModule.kt @@ -11,22 +11,14 @@ import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi import com.zaneschepke.wireguardautotunnel.data.network.KtorClient import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi -import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository -import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository -import com.zaneschepke.wireguardautotunnel.data.repository.GitHubUpdateRepository -import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository -import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository +import com.zaneschepke.wireguardautotunnel.data.repository.* +import com.zaneschepke.wireguardautotunnel.domain.repository.* import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import io.ktor.client.HttpClient +import io.ktor.client.* import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher 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 cb2e46b1..98bbd8f7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt @@ -4,16 +4,15 @@ import android.content.Context import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell import com.wireguard.android.util.ToolsInstaller +import com.zaneschepke.logcatter.LogReader import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider -import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel +import com.zaneschepke.wireguardautotunnel.core.tunnel.* import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository +import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -108,7 +107,6 @@ class TunnelModule { appDataRepository, applicationScope, ioDispatcher, - notificationManager, ) } @@ -153,4 +151,23 @@ class TunnelModule { appDataRepository, ) } + + @Singleton + @Provides + fun provideTunnelMonitor( + @ApplicationContext context: Context, + tunnelManager: TunnelManager, + networkMonitor: NetworkMonitor, + networkUtils: NetworkUtils, + logReader: LogReader, + appDataRepository: AppDataRepository, + ): TunnelMonitor { + return TunnelMonitor( + appDataRepository, + tunnelManager, + networkMonitor, + networkUtils, + logReader, + ) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt index 8266885e..7c2a158d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt @@ -10,10 +10,12 @@ sealed class TunnelStatus { data object Starting : TunnelStatus() - enum class StopReason { - USER, - PING, - CONFIG_CHANGED, + sealed class StopReason { + data object User : StopReason() + + data class Ping(val previouslyResolvedEndpoints: Map) : StopReason() + + data object ConfigChanged : StopReason() } fun isDown(): Boolean { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt index a6aa36d6..7f28919c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/AutoTunnelEvent.kt @@ -5,7 +5,14 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf sealed class AutoTunnelEvent { data class Start(val tunnelConf: TunnelConf? = null) : AutoTunnelEvent() + data class Bounce(val configsPeerKeyResolvedMap: List>>) : + AutoTunnelEvent() + data object Stop : AutoTunnelEvent() data object DoNothing : AutoTunnelEvent() + + data class StartKillSwitch(val allowedIps: List) : AutoTunnelEvent() + + data object StopKillSwitch : AutoTunnelEvent() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendError.kt similarity index 59% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendError.kt index be9fb9e7..4e00b4eb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendError.kt @@ -1,6 +1,7 @@ -package com.zaneschepke.wireguardautotunnel.domain.enums +package com.zaneschepke.wireguardautotunnel.domain.events import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.util.StringValue sealed class BackendError : Exception() { data object DNS : BackendError() @@ -19,6 +20,8 @@ sealed class BackendError : Exception() { data object TunnelNameTooLong : BackendError() + data class BounceFailed(val error: BackendError) : BackendError() + fun toStringRes() = when (this) { Config -> R.string.config_error @@ -29,5 +32,17 @@ sealed class BackendError : Exception() { ServiceNotRunning -> R.string.service_running_error Unknown -> R.string.unknown_error TunnelNameTooLong -> R.string.error_tunnel_name + is BounceFailed -> R.string.bounce_failed_template } + + fun toStringValue(): StringValue { + return when (val backendError = this) { + is BounceFailed -> + StringValue.StringResource( + backendError.toStringRes(), + backendError.error.toStringRes(), + ) + else -> StringValue.StringResource(backendError.toStringRes()) + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt new file mode 100644 index 00000000..b5d4e74c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt @@ -0,0 +1,19 @@ +package com.zaneschepke.wireguardautotunnel.domain.events + +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.util.StringValue + +sealed class BackendMessage { + + data object BounceSuccess : BackendMessage() + + data object BounceRecovery : BackendMessage() + + fun toStringRes() = + when (this) { + BounceRecovery -> R.string.pinger_bounce_recovery + BounceSuccess -> R.string.pinger_bounce_successful + } + + fun toStringValue() = StringValue.StringResource(this.toStringRes()) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt index 36282028..0e3a7b5f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AppSettings.kt @@ -24,14 +24,12 @@ data class AppSettings( val debounceDelaySeconds: Int = 3, val isDisableKillSwitchOnTrustedEnabled: Boolean = false, val isTunnelOnUnsecureEnabled: Boolean = false, - val splitTunnelApps: List = emptyList(), val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod = AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT, + val tunnelPingIntervalSeconds: Int = 30, + val tunnelPingAttempts: Int = 3, + val tunnelPingTimeoutSeconds: Int? = null, ) { - fun debounceDelayMillis(): Long { - return debounceDelaySeconds * 1000L - } - fun toAutoTunnelStateString(): String { return """ TunnelOnWifi: $isTunnelOnWifiEnabled 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 601b110b..c152b52b 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 @@ -9,6 +9,7 @@ data class AppState( val expandedTunnelIds: List, val isLocalLogsEnabled: Boolean, val isRemoteControlEnabled: Boolean, + val showDetailedPingStats: Boolean, val remoteKey: String?, val locale: String?, val theme: Theme, 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 ddb88743..cac8469a 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 @@ -2,14 +2,9 @@ package com.zaneschepke.wireguardautotunnel.domain.model import com.wireguard.android.backend.Tunnel import com.wireguard.config.Config -import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.* import java.io.InputStream -import java.net.InetAddress import java.nio.charset.StandardCharsets -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.withContext -import timber.log.Timber data class TunnelConf( val id: Int = 0, @@ -20,10 +15,8 @@ data class TunnelConf( val isPrimaryTunnel: Boolean = false, val amQuick: String, val isActive: Boolean = false, - val isPingEnabled: Boolean = false, - val pingInterval: Long? = null, - val pingCooldown: Long? = null, - val pingIp: String? = null, + val pingTarget: String? = null, + val restartOnPingFailure: Boolean = false, val isEthernetTunnel: Boolean = false, val isIpv4Preferred: Boolean = true, val position: Int = 0, @@ -46,10 +39,8 @@ data class TunnelConf( isPrimaryTunnel == other.isPrimaryTunnel && isMobileDataTunnel == other.isMobileDataTunnel && isEthernetTunnel == other.isEthernetTunnel && - isPingEnabled == other.isPingEnabled && - pingIp == other.pingIp && - pingCooldown == other.pingCooldown && - pingInterval == other.pingInterval && + pingTarget == other.pingTarget && + restartOnPingFailure == other.restartOnPingFailure && tunnelNetworks == other.tunnelNetworks && isIpv4Preferred == other.isIpv4Preferred } @@ -75,10 +66,8 @@ data class TunnelConf( isPrimaryTunnel: Boolean = this.isPrimaryTunnel, amQuick: String = this.amQuick, isActive: Boolean = this.isActive, - isPingEnabled: Boolean = this.isPingEnabled, - pingInterval: Long? = this.pingInterval, - pingCooldown: Long? = this.pingCooldown, - pingIp: String? = this.pingIp, + restartOnPingFailure: Boolean = this.restartOnPingFailure, + pingIp: String? = this.pingTarget, isEthernetTunnel: Boolean = this.isEthernetTunnel, isIpv4Preferred: Boolean = this.isIpv4Preferred, ): TunnelConf { @@ -91,10 +80,8 @@ data class TunnelConf( isPrimaryTunnel, amQuick, isActive, - isPingEnabled, - pingInterval, - pingCooldown, pingIp, + restartOnPingFailure, isEthernetTunnel, isIpv4Preferred, position, @@ -138,21 +125,6 @@ data class TunnelConf( return tunnelName } - suspend fun isTunnelPingable(context: CoroutineContext): Boolean { - return withContext(context) { - val config = toWgConfig() - if (pingIp != null) { - return@withContext InetAddress.getByName(pingIp) - .isReachable(Constants.PING_TIMEOUT.toInt()) - .also { Timber.i("Ping reachable $pingIp: $it") } - } - config.peers - .map { peer -> peer.isReachable() } - .all { true } - .also { Timber.i("Ping of all peers reachable: $it") } - } - } - companion object { fun configFromWgQuick(wgQuick: String): Config { val inputStream: InputStream = wgQuick.byteInputStream() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppStateRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppStateRepository.kt index 26f9d535..7f40fbf9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppStateRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/repository/AppStateRepository.kt @@ -41,5 +41,9 @@ interface AppStateRepository { suspend fun getRemoteKey(): String? + suspend fun setShowDetailedPingStats(showDetailedPing: Boolean) + + suspend fun getShowDetailedPing(): Boolean + val flow: Flow } 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 314a40d9..12918e73 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 @@ -1,10 +1,8 @@ package com.zaneschepke.wireguardautotunnel.domain.state -import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown -import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive -import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent -import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent +import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.* import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList @@ -16,6 +14,68 @@ data class AutoTunnelState( val tunnels: List = emptyList(), ) { + fun determineAutoTunnelEvent(stateChange: StateChange): AutoTunnelEvent { + when (val change = stateChange) { + is StateChange.NetworkChange, + is StateChange.SettingsChange -> { + // Compute desired tunnel based on network conditions + var desiredTunnel: TunnelConf? = null + if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) { + desiredTunnel = preferredEthernetTunnel() + } else if (isMobileDataActive() && settings.isTunnelOnMobileDataEnabled) { + desiredTunnel = preferredMobileDataTunnel() + } else if ( + isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted() + ) { + desiredTunnel = preferredWifiTunnel() + } + + // Override for no connectivity if enabled + if (isNoConnectivity() && settings.isStopOnNoInternetEnabled) { + desiredTunnel = null + } + + // Determine current active tunnel (assuming only one can be active) + val currentTunnel = activeTunnels.entries.firstOrNull()?.key + + // Handle tunnel start/stop/change + if (desiredTunnel != null) { + if (currentTunnel != desiredTunnel) { + // Start or switch to the desired tunnel (overrides any kill switch) + return Start(desiredTunnel) + } + // If already active and matching, fall through to kill switch check (though + // unlikely needed) + } else { + if (currentTunnel != null) { + // Stop the active tunnel (then next emission can handle kill switch if + // needed) + return AutoTunnelEvent.Stop + } + } + // Handle kill switch only if no user tunnel is or will be active + if (stopKillSwitchOnTrusted()) { + return AutoTunnelEvent.StopKillSwitch + } + if (startKillSwitch()) { + val allowedIps = + if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS + else emptyList() + return StartKillSwitch(allowedIps) + } + } + is StateChange.MonitoringChange -> { + val bounceTunnels = bounceOnPingFailed() + if (bounceTunnels.isNotEmpty()) { + return Bounce(bounceTunnels) + } + } + + is StateChange.ActiveTunnelsChange -> Unit + } + return DoNothing + } + // also need to check for Wi-Fi state as there is some overlap when they are both connected private fun isMobileDataActive(): Boolean { return !networkState.isEthernetConnected && @@ -23,32 +83,22 @@ data class AutoTunnelState( networkState.isMobileDataConnected } - private fun isMobileTunnelDataChangeNeeded(): Boolean { - val preferredTunnel = preferredMobileDataTunnel() - return preferredTunnel != null && - activeTunnels.isNotEmpty() && - !activeTunnels.isUp(preferredTunnel) - } - - private fun isEthernetTunnelChangeNeeded(): Boolean { - val preferredTunnel = preferredEthernetTunnel() - return preferredTunnel != null && - activeTunnels.isNotEmpty() && - !activeTunnels.isUp(preferredTunnel) - } - private fun preferredMobileDataTunnel(): TunnelConf? { return tunnels.firstOrNull { it.isMobileDataTunnel } ?: tunnels.firstOrNull { it.isPrimaryTunnel } + ?: tunnels.firstOrNull() } private fun preferredEthernetTunnel(): TunnelConf? { return tunnels.firstOrNull { it.isEthernetTunnel } ?: tunnels.firstOrNull { it.isPrimaryTunnel } + ?: tunnels.firstOrNull() } private fun preferredWifiTunnel(): TunnelConf? { - return getTunnelWithMatchingTunnelNetwork() ?: tunnels.firstOrNull { it.isPrimaryTunnel } + return getTunnelWithMatchingTunnelNetwork() + ?: tunnels.firstOrNull { it.isPrimaryTunnel } + ?: tunnels.firstOrNull() } // ignore cellular state as there is overlap where it may still be active, but not prioritized @@ -56,19 +106,6 @@ data class AutoTunnelState( return !networkState.isEthernetConnected && networkState.isWifiConnected } - private fun startOnEthernet(): Boolean { - return networkState.isEthernetConnected && - settings.isTunnelOnEthernetEnabled && - activeTunnels.allDown() - } - - private fun stopOnEthernet(): Boolean { - return networkState.isEthernetConnected && - !settings.isTunnelOnEthernetEnabled && - activeTunnels.hasActive() - } - - // TODO test removed kill switch state check private fun stopKillSwitchOnTrusted(): Boolean { return networkState.isWifiConnected && settings.isVpnKillSwitchEnabled && @@ -76,7 +113,6 @@ data class AutoTunnelState( isCurrentSSIDTrusted() } - // TODO test, removed kill switch state check private fun startKillSwitch(): Boolean { return settings.isVpnKillSwitchEnabled && (!settings.isDisableKillSwitchOnTrustedEnabled || !isCurrentSSIDTrusted()) @@ -88,93 +124,21 @@ data class AutoTunnelState( !networkState.isMobileDataConnected } - private fun stopOnMobileData(): Boolean { - return isMobileDataActive() && - !settings.isTunnelOnMobileDataEnabled && - activeTunnels.hasActive() - } - - private fun startOnMobileData(): Boolean { - return isMobileDataActive() && - settings.isTunnelOnMobileDataEnabled && - activeTunnels.allDown() - } - - private fun changeOnMobileData(): Boolean { - return isMobileDataActive() && - settings.isTunnelOnMobileDataEnabled && - isMobileTunnelDataChangeNeeded() - } - - private fun changeOnEthernet(): Boolean { - return networkState.isEthernetConnected && - settings.isTunnelOnEthernetEnabled && - isEthernetTunnelChangeNeeded() - } - - private fun stopOnWifi(): Boolean { - return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() - } - - private fun stopOnTrustedWifi(): Boolean { - return isWifiActive() && - settings.isTunnelOnWifiEnabled && - activeTunnels.hasActive() && - isCurrentSSIDTrusted() - } - - private fun startOnUntrustedWifi(): Boolean { - return isWifiActive() && - settings.isTunnelOnWifiEnabled && - activeTunnels.allDown() && - !isCurrentSSIDTrusted() - } - - private fun changeOnUntrustedWifi(): Boolean { - return isWifiActive() && - settings.isTunnelOnWifiEnabled && - activeTunnels.hasActive() && - !isCurrentSSIDTrusted() && - !isWifiTunnelPreferred() - } - - private fun isWifiTunnelPreferred(): Boolean { - val preferred = preferredWifiTunnel() - return preferred?.let { activeTunnels.isUp(it) } ?: true - } - - fun asAutoTunnelEvent(): AutoTunnelEvent { - return when { - // ethernet scenarios - stopOnEthernet() -> AutoTunnelEvent.Stop - startOnEthernet() || changeOnEthernet() -> - AutoTunnelEvent.Start(preferredEthernetTunnel()) - // mobile data scenarios - stopOnMobileData() -> AutoTunnelEvent.Stop - startOnMobileData() || changeOnMobileData() -> - AutoTunnelEvent.Start(preferredMobileDataTunnel()) - // wifi scenarios - stopOnWifi() -> AutoTunnelEvent.Stop - stopOnTrustedWifi() -> AutoTunnelEvent.Stop - startOnUntrustedWifi() || changeOnUntrustedWifi() -> - AutoTunnelEvent.Start(preferredWifiTunnel()) - // no connectivity - isNoConnectivity() && settings.isStopOnNoInternetEnabled -> AutoTunnelEvent.Stop - else -> AutoTunnelEvent.DoNothing - } - } - - fun asKillSwitchEvent(): KillSwitchEvent { - return when { - stopKillSwitchOnTrusted() -> KillSwitchEvent.Stop - startKillSwitch() -> { - val allowedIps = - if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS - else emptyList() - KillSwitchEvent.Start(allowedIps) + private fun bounceOnPingFailed(): List>> { + return activeTunnels.entries + .filter { (tunnel, state) -> + tunnel.restartOnPingFailure && + (state.pingStates?.any { (key, pingState) -> + pingState.failureReason == FailureReason.PingFailed + } ?: false) + } + .map { (tunnel, state) -> + val peerMap = + (state.statistics?.getPeers()?.associate { peerKey -> + peerKey.toBase64() to state.statistics.peerStats(peerKey)?.resolvedEndpoint + } ?: emptyMap()) + Pair(tunnel, peerMap) } - else -> KillSwitchEvent.DoNothing - } } private fun isCurrentSSIDTrusted(): Boolean { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt new file mode 100644 index 00000000..d8e93015 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/PingState.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.wireguardautotunnel.domain.state + +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor.Companion.CLOUDFLARE_IPV4_IP + +enum class FailureReason { + NoConnectivity, + PingFailed, + NoResolvedEndpoint, + Timeout, + Unknown, +} + +data class PingState( + val transmitted: Int = 0, + val received: Int = 0, + val packetLoss: Double = 0.0, + val rttMin: Double = 0.0, + val rttMax: Double = 0.0, + val rttAvg: Double = 0.0, + val rttStddev: Double = 0.0, + val isReachable: Boolean = false, + val lastSuccessfulPingMillis: Long? = null, + val lastPingAttemptMillis: Long? = null, + val failureReason: FailureReason? = null, + val pingTarget: String = CLOUDFLARE_IPV4_IP, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt index 164e4d7b..f20171a6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelState.kt @@ -2,9 +2,12 @@ package com.zaneschepke.wireguardautotunnel.domain.state import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import org.amnezia.awg.crypto.Key data class TunnelState( val status: TunnelStatus = TunnelStatus.Down, val backendState: BackendState = BackendState.INACTIVE, val statistics: TunnelStatistics? = null, + val pingStates: Map? = null, + val handshakeSuccessLogs: Boolean? = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelStatistics.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelStatistics.kt index 1829cec7..d4fc29fe 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelStatistics.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/state/TunnelStatistics.kt @@ -3,13 +3,24 @@ package com.zaneschepke.wireguardautotunnel.domain.state import org.amnezia.awg.crypto.Key abstract class TunnelStatistics { - @JvmRecord - data class PeerStats( + open class PeerStats( val rxBytes: Long, val txBytes: Long, val latestHandshakeEpochMillis: Long, val resolvedEndpoint: String, - ) + ) { + // mimic data class copy + open fun copy( + rxBytes: Long = this.rxBytes, + txBytes: Long = this.txBytes, + latestHandshakeEpochMillis: Long = this.latestHandshakeEpochMillis, + resolvedEndpoint: String = this.resolvedEndpoint, + ): PeerStats = PeerStats(rxBytes, txBytes, latestHandshakeEpochMillis, resolvedEndpoint) + + // Manual toString: Format like data class + override fun toString(): String = + "PeerStats(rxBytes=$rxBytes, txBytes=$txBytes, latestHandshakeEpochMillis=$latestHandshakeEpochMillis, resolvedEndpoint=$resolvedEndpoint)" + } abstract fun peerStats(peer: Key): PeerStats? diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt index 38f4acee..c1bc6f2b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt @@ -47,4 +47,6 @@ sealed class Route { @Serializable data object Logs : Route() @Serializable data object Sort : Route() + + @Serializable data object TunnelMonitoring : Route() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt index 843d10f3..1c6345b1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common.animation -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt index 854ec7a4..f4c64ee7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/ClickableIconButton.kt @@ -3,11 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector 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 64996a36..d5b64e95 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 @@ -2,18 +2,9 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/SubmitConfigurationTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/SubmitConfigurationTextBox.kt index 725975ac..a9335c1e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/SubmitConfigurationTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/SubmitConfigurationTextBox.kt @@ -12,11 +12,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -32,6 +28,7 @@ fun SubmitConfigurationTextBox( hint: String, isErrorValue: (value: String?) -> Boolean, onSubmit: (value: String) -> Unit, + supportingText: @Composable (() -> Unit)? = null, keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done), ) { @@ -49,6 +46,7 @@ fun SubmitConfigurationTextBox( value = stateValue, onValueChange = { stateValue = it }, interactionSource = interactionSource, + supportingText = supportingText, label = { Text( label, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt index 3a52c10c..47600ab3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dialog/InfoDialog.kt @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common.dialog -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/DropdownSelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt similarity index 68% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/DropdownSelector.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt index 2e607930..f96ac442 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/DropdownSelector.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/DropdownSelector.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.common +package com.zaneschepke.wireguardautotunnel.ui.common.dropdown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -6,11 +6,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,9 +16,9 @@ import com.zaneschepke.wireguardautotunnel.R @Composable fun DropdownSelector( - currentValue: T, - options: List, - onValueSelected: (T) -> Unit, + currentValue: T?, + options: List, + onValueSelected: (T?) -> Unit, modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null, isExpanded: Boolean = false, @@ -33,7 +29,10 @@ fun DropdownSelector( verticalAlignment = Alignment.CenterVertically, ) { if (label != null) label() - Text(text = currentValue.toString(), style = MaterialTheme.typography.bodyMedium) + Text( + text = currentValue?.toString() ?: stringResource(R.string._default), + style = MaterialTheme.typography.bodyMedium, + ) Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown)) } DropdownMenu( @@ -44,11 +43,20 @@ fun DropdownSelector( onDismissRequest = onDismiss, ) { options.forEach { option -> + if (option == null) { + return@forEach DropdownMenuItem( + text = { Text(text = stringResource(R.string._default)) }, + onClick = { + onValueSelected(null) + onDismiss() + }, + ) + } DropdownMenuItem( text = { Text(text = option.toString()) }, onClick = { onValueSelected(option) - onDismiss() // Close dropdown after selection + onDismiss() }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt new file mode 100644 index 00000000..f42acb8c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.dropdown + +import androidx.compose.runtime.* +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton + +@Composable +fun LabelledNumberDropdown( + title: @Composable () -> Unit, + description: (@Composable () -> Unit)? = null, + leading: @Composable () -> Unit, + onSelected: (Int?) -> Unit, + options: List, + currentValue: Int?, +) { + var isDropDownExpanded by remember { mutableStateOf(false) } + SurfaceSelectionGroupButton( + listOf( + SelectionItem( + leading = leading, + title = title, + description = description, + onClick = { isDropDownExpanded = true }, + trailing = { + DropdownSelector( + currentValue = currentValue, + options = options, + onValueSelected = { num -> onSelected(num) }, + isExpanded = isDropDownExpanded, + onDismiss = { isDropDownExpanded = false }, + ) + }, + ) + ) + ) +} 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 86cf9d25..6c3ee958 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 @@ -1,12 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common.snackbar -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Info 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 index ffcf87c5..59d381b2 100644 --- 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 @@ -163,6 +163,12 @@ fun currentNavBackStackEntryAsNavBarState( route = Route.Display, ) + backStackEntry.isCurrentRoute(Route.TunnelMonitoring::class) -> + NavBarState( + topTitle = { Text(stringResource(R.string.tunnel_monitoring)) }, + route = Route.TunnelMonitoring, + ) + backStackEntry.isCurrentRoute(Route.WifiDetectionMethod::class) -> NavBarState( topTitle = { Text(stringResource(R.string.wifi_detection_method)) }, 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 ac84c46c..41266a80 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 @@ -9,11 +9,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 e1b1d156..b5d0ea42 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 @@ -6,13 +6,21 @@ 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.outlined.PauseCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components.DebounceDelaySelector +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledNumberDropdown import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) { @@ -25,9 +33,22 @@ fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) { .padding(vertical = 24.dp) .padding(horizontal = 12.dp), ) { - DebounceDelaySelector( - currentDelay = appUiState.appSettings.debounceDelaySeconds, - onEvent = viewModel::handleEvent, + LabelledNumberDropdown( + title = { + Text( + stringResource(R.string.debounce_delay), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + ) + }, + leading = { Icon(Icons.Outlined.PauseCircle, null) }, + onSelected = { selected -> + viewModel.handleEvent(AppEvent.SetDebounceDelay(selected!!)) + }, + options = (0..10).toList(), + currentValue = appUiState.appSettings.debounceDelaySeconds, ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/components/DebounceDelaySelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/components/DebounceDelaySelector.kt deleted file mode 100644 index e005780d..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/components/DebounceDelaySelector.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.PauseCircle -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.res.stringResource -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.common.DropdownSelector -import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent - -@Composable -fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) { - var isDropDownExpanded by remember { mutableStateOf(false) } - - SurfaceSelectionGroupButton( - listOf( - SelectionItem( - leading = { Icon(Icons.Outlined.PauseCircle, contentDescription = null) }, - title = { - Text( - stringResource(R.string.debounce_delay), - style = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface - ), - ) - }, - onClick = { isDropDownExpanded = true }, - trailing = { - DropdownSelector( - currentValue = currentDelay, - options = (0..10).toList(), - onValueSelected = { num -> onEvent(AppEvent.SetDebounceDelay(num)) }, - isExpanded = isDropDownExpanded, - onDismiss = { isDropDownExpanded = false }, - ) - }, - ) - ) - ) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt index c1334c3f..76741298 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/TrustNetworksTextBox.kt @@ -1,12 +1,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons 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 index b936f99f..7abab837 100644 --- 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 @@ -6,12 +6,7 @@ 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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -19,6 +14,7 @@ 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 @@ -45,6 +41,9 @@ fun TunnelAutoTunnelScreen( SurfaceSelectionGroupButton( items = buildList { + if (appSettings.isPingEnabled) { + add(PingRestartItem(tunnelConf, viewModel)) + } add(MobileDataTunnelItem(tunnelConf, viewModel)) add(ethernetTunnelItem(tunnelConf, viewModel)) add( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PingRestartItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt similarity index 82% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PingRestartItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt index db277a1e..9526699d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PingRestartItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/autotunnel/components/PingRestartItem.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components +package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NetworkPing @@ -27,10 +27,10 @@ fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionI }, trailing = { ScaledSwitch( - checked = tunnelConf.isPingEnabled, - onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) }, + checked = tunnelConf.restartOnPingFailure, + onClick = { viewModel.handleEvent(AppEvent.ToggleRestartOnPingFailure(tunnelConf)) }, ) }, - onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) }, + onClick = { viewModel.handleEvent(AppEvent.ToggleRestartOnPingFailure(tunnelConf)) }, ) } 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/main/autotunnel/components/WifiTunnelItem.kt index 6bc96bc1..1a3ca572 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/main/autotunnel/components/WifiTunnelItem.kt @@ -1,10 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Security import androidx.compose.material3.Icon 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/main/components/TunnelImportSheet.kt index 283967a8..1efd56f2 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/main/components/TunnelImportSheet.kt @@ -5,18 +5,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentPasteGo -import androidx.compose.material.icons.filled.Create -import androidx.compose.material.icons.filled.FileOpen -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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/main/components/TunnelList.kt index a8ef3cd9..fca14f7f 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/main/components/TunnelList.kt @@ -69,6 +69,7 @@ fun TunnelList( isSelected = selected, tunnel = tunnel, tunnelState = tunnelState, + appSettings = appUiState.appSettings, onTvClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) viewModel.handleEvent(AppEvent.ClearSelectedTunnels) @@ -78,6 +79,7 @@ fun TunnelList( }, onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) }, isTv = isTv, + showDetailedStats = appUiState.appState.showDetailedPingStats, modifier = if (!isTv) Modifier.combinedClickable( 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/main/components/TunnelRowItem.kt index 7ab38ac5..cac41578 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/main/components/TunnelRowItem.kt @@ -4,11 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Circle -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.SettingsEthernet -import androidx.compose.material.icons.rounded.Smartphone -import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.* import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,6 +20,7 @@ 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 @@ -36,10 +33,12 @@ fun TunnelRowItem( isSelected: Boolean, tunnel: TunnelConf, tunnelState: TunnelState, + appSettings: AppSettings, onTvClick: () -> Unit, onToggleSelectedTunnel: (TunnelConf) -> Unit, onSwitchClick: (Boolean) -> Unit, isTv: Boolean, + showDetailedStats: Boolean, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -113,7 +112,12 @@ fun TunnelRowItem( text = tunnel.tunName, expanded = { if (tunnelState.status != TunnelStatus.Down) { - TunnelStatisticsRow(tunnelState.statistics, tunnel) + TunnelStatisticsRow( + tunnelState, + tunnel, + appSettings.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/main/components/TunnelStatisticsRow.kt index d6d3b823..b96eeaa4 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/main/components/TunnelStatisticsRow.kt @@ -4,106 +4,209 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* 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.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.TunnelConf -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString @Composable -fun TunnelStatisticsRow(statistics: TunnelStatistics?, tunnelConf: TunnelConf) { - val config = TunnelConf.configFromAmQuick(tunnelConf.wgQuick) +fun TunnelStatisticsRow( + tunnelState: TunnelState, + tunnelConf: TunnelConf, + pingEnabled: Boolean, + showDetailedStats: Boolean, +) { + val config = remember(tunnelConf) { TunnelConf.configFromAmQuick(tunnelConf.wgQuick) } + val peerText = stringResource(R.string.peer) + val handshakeText = stringResource(R.string.handshake) + val endpointText = stringResource(R.string.endpoint) + val neverText = stringResource(R.string.never) + val textStyle = MaterialTheme.typography.bodySmall + val textColor = MaterialTheme.colorScheme.outline + Column( modifier = Modifier.fillMaxWidth().padding(start = 45.dp, bottom = 10.dp, end = 10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.Start, ) { - config.peers.forEach { peer -> - val peerId = remember { peer.publicKey.toBase64().subSequence(0, 3).toString() + "***" } - val endpoint = - remember(statistics) { statistics?.peerStats(peer.publicKey)?.resolvedEndpoint } - val peerRxMB by - remember(statistics) { - derivedStateOf { - statistics - ?.peerStats(peer.publicKey) - ?.rxBytes - ?.let { NumberUtils.bytesToMB(it) } - ?.toThreeDecimalPlaceString() + config.peers.forEachIndexed { index, peer -> + key(peer.publicKey.toBase64()) { // Key by peer ID to skip recomposition if unchanged + val peerStats = + remember(tunnelState.statistics, peer, tunnelConf) { + tunnelState.statistics?.peerStats(peer.publicKey) } - } - val peerTxMB by - remember(statistics) { - derivedStateOf { - statistics - ?.peerStats(peer.publicKey) - ?.txBytes - ?.let { NumberUtils.bytesToMB(it) } - ?.toThreeDecimalPlaceString() + val peerId = + remember(peer) { + peer.publicKey.toBase64().subSequence(0, 3).toString() + "***" } - } - val handshake by - remember(statistics) { - derivedStateOf { - statistics?.peerStats(peer.publicKey)?.latestHandshakeEpochMillis?.let { - if (it == 0L) { - null - } else { - "${NumberUtils.getSecondsBetweenTimestampAndNow(it)}" + val endpoint by + remember(peerStats) { derivedStateOf { peerStats?.resolvedEndpoint } } + val peerRxMB by + remember(peerStats) { + derivedStateOf { + peerStats + ?.rxBytes + ?.let { NumberUtils.bytesToMB(it) } + ?.toThreeDecimalPlaceString() ?: "0.00" + } + } + val peerTxMB by + remember(peerStats) { + derivedStateOf { + peerStats + ?.txBytes + ?.let { NumberUtils.bytesToMB(it) } + ?.toThreeDecimalPlaceString() ?: "0.00" + } + } + val handshake by + remember(peerStats) { + derivedStateOf { + peerStats?.latestHandshakeEpochMillis?.let { + if (it == 0L) null + else NumberUtils.getSecondsBetweenTimestampAndNow(it).toString() } } } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), - ) { - Text( - stringResource(R.string.peer).lowercase() + ": $peerId", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - Text( - stringResource(R.string.handshake) + - ": ${if(handshake == null) stringResource(R.string.never) else handshake + " " + stringResource(R.string.sec)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), - ) { - Text( - "rx: ${peerRxMB ?: 0.00} MB", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - Text( - "tx: ${peerTxMB ?: 0.00} MB", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, - ) - } - if (endpoint != null) { - AnimatedVisibility(visible = true) { + val pingState by + remember(tunnelState.pingStates) { + derivedStateOf { + tunnelState.pingStates?.getOrDefault(peer.publicKey, null) + } + } + val lastPingedSeconds by + remember(peerStats) { + derivedStateOf { + pingState?.lastSuccessfulPingMillis?.let { + NumberUtils.getSecondsBetweenTimestampAndNow(it) + } + } + } + + // Group peer stats in a column with internal spacing + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("$peerText: $peerId", style = textStyle, color = textColor) + Text( + "$handshakeText: ${handshake?.let { stringResource(R.string.sec_ago_template, it)} ?: neverText}", + style = textStyle, + color = textColor, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - stringResource(R.string.endpoint).lowercase() + ": $endpoint", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline, + stringResource(R.string.rx_template, peerRxMB), + style = textStyle, + color = textColor, ) + Text( + stringResource(R.string.tx_template, peerTxMB), + style = textStyle, + color = textColor, + ) + } + AnimatedVisibility(visible = endpoint != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("$endpointText: $endpoint", style = textStyle, color = textColor) + } + } + AnimatedVisibility(visible = pingState != null && pingEnabled) { + pingState?.let { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource( + R.string.reachable_template, + stringResource( + if (it.isReachable) R.string._true + else R.string._false + ), + ), + style = textStyle, + color = textColor, + ) + Text( + stringResource( + R.string.ping_target_template, + it.pingTarget, + ), + style = textStyle, + color = textColor, + ) + } + if (showDetailedStats) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource(R.string.latency_template, it.rttAvg), + style = textStyle, + color = textColor, + ) + Text( + stringResource(R.string.jitter_template, it.rttStddev), + style = textStyle, + color = textColor, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource( + R.string.packets_sent_template, + it.transmitted, + ), + style = textStyle, + color = textColor, + ) + Text( + stringResource( + R.string.packet_loss_template, + it.packetLoss, + ), + style = textStyle, + color = textColor, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource( + R.string.ping_success_template, + lastPingedSeconds?.let { sec -> + stringResource(R.string.sec_ago_template, sec) + } ?: neverText, + ), + style = textStyle, + color = textColor, + ) + } + } + } + } } } } 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/main/components/UrlImportDialog.kt index bfeddd37..e6094743 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/main/components/UrlImportDialog.kt @@ -7,11 +7,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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/main/config/components/InterfaceDropdown.kt index 5fe3af5c..e7de8401 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/main/config/components/InterfaceDropdown.kt @@ -5,12 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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/main/config/components/InterfaceFields.kt index a680606b..3ef79a9c 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/main/config/components/InterfaceFields.kt @@ -9,11 +9,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController 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 index f9756c58..e78a81f4 100644 --- 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 @@ -1,19 +1,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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/main/config/components/PeersSection.kt index d6d732e4..52961e7f 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/main/config/components/PeersSection.kt @@ -2,28 +2,13 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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 index 95dceffe..b8dad4b2 100644 --- 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 @@ -18,11 +18,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.text.Collator import java.util.* import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @HiltViewModel 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 index 9b17eaaa..e29746b4 100644 --- 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 @@ -2,11 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.componen import android.content.pm.PackageManager import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Checkbox import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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/main/splittunnel/components/AppListSection.kt index 7cbcb114..1bc347da 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/main/splittunnel/components/AppListSection.kt @@ -1,12 +1,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions 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/main/splittunnel/components/SplitOptionsSelector.kt index 14fd80e5..7d424145 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/main/splittunnel/components/SplitOptionsSelector.kt @@ -8,12 +8,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MultiChoiceSegmentedButtonRow -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 index b46a44ea..37e682fb 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -25,6 +26,7 @@ fun TunnelOptionsScreen( tunnelConf: TunnelConf, viewModel: AppViewModel, appViewState: AppViewState, + appSettings: AppSettings, ) { val isTv = LocalIsAndroidTV.current @@ -36,7 +38,10 @@ fun TunnelOptionsScreen( // Show authorization prompt if needed if (showAuthPrompt) { AuthorizationPromptWrapper( - onDismiss = { showAuthPrompt = false }, + onDismiss = { + showAuthPrompt = false + viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE)) + }, onSuccess = { showAuthPrompt = false isAuthorized = true @@ -72,15 +77,9 @@ fun TunnelOptionsScreen( SplitTunnelingItem(tunnelConf), ) ) - SectionDivider() - SurfaceSelectionGroupButton( - items = - buildList { - add(PingRestartItem(tunnelConf, viewModel)) - if (tunnelConf.isPingEnabled) { - add(PingConfigItem(tunnelConf, viewModel)) - } - } - ) + if (appSettings.isPingEnabled) { + SectionDivider() + SurfaceSelectionGroupButton(items = listOf(pingConfigItem(tunnelConf, viewModel))) + } } } 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/main/tunneloptions/components/PingConfigItem.kt deleted file mode 100644 index 11b8a58a..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/PingConfigItem.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf -import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem -import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.util.Constants -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 { - return SelectionItem( - title = {}, - description = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SubmitConfigurationTextBox( - value = tunnelConf.pingIp, - label = stringResource(R.string.set_custom_ping_ip), - hint = stringResource(R.string.default_ping_ip), - isErrorValue = { it?.isNotBlank() == true && !it.isValidIpv4orIpv6Address() }, - onSubmit = { ip -> - viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip)) - }, - ) - SubmitConfigurationTextBox( - value = tunnelConf.pingInterval?.let { (it / 1000).toString() }, - label = stringResource(R.string.set_custom_ping_internal), - hint = - "(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})", - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - isErrorValue = { - it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false - }, - onSubmit = { interval -> - viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval)) - }, - ) - SubmitConfigurationTextBox( - value = tunnelConf.pingCooldown?.let { (it / 1000).toString() }, - label = stringResource(R.string.set_custom_ping_cooldown), - hint = - "(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})", - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - isErrorValue = { - it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false - }, - onSubmit = { cooldown -> - viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) - }, - ) - } - }, - ) -} 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/main/tunneloptions/components/QrCodeDialog.kt index 54996d52..7e53a6c0 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/main/tunneloptions/components/QrCodeDialog.kt @@ -2,30 +2,13 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.VpnKey -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MultiChoiceSegmentedButtonRow -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,15 +21,7 @@ import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.util.extensions.setScreenBrightness -import io.github.alexzhirkevich.qrose.options.QrBallShape -import io.github.alexzhirkevich.qrose.options.QrBrush -import io.github.alexzhirkevich.qrose.options.QrErrorCorrectionLevel -import io.github.alexzhirkevich.qrose.options.QrFrameShape -import io.github.alexzhirkevich.qrose.options.QrOptions -import io.github.alexzhirkevich.qrose.options.QrPixelShape -import io.github.alexzhirkevich.qrose.options.circle -import io.github.alexzhirkevich.qrose.options.roundCorners -import io.github.alexzhirkevich.qrose.options.solid +import io.github.alexzhirkevich.qrose.options.* import io.github.alexzhirkevich.qrose.rememberQrCodePainter @Composable 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/main/tunneloptions/components/pingConfigItem.kt new file mode 100644 index 00000000..685db54a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/tunneloptions/components/pingConfigItem.kt @@ -0,0 +1,40 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem +import com.zaneschepke.wireguardautotunnel.ui.common.config.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 { + return SelectionItem( + title = {}, + description = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SubmitConfigurationTextBox( + value = tunnelConf.pingTarget, + label = stringResource(R.string.set_custom_ping_target), + hint = stringResource(R.string.ip_or_hostname), + isErrorValue = { + it?.isNotBlank() == true && + !it.isValidIpv4orIpv6Address() && + !android.util.Patterns.DOMAIN_NAME.matcher(it).matches() + }, + supportingText = { Text(stringResource(R.string.ping_target_description)) }, + onSubmit = { ip -> + viewModel.handleEvent(AppEvent.SetTunnelPingTarget(tunnelConf, ip)) + }, + ) + } + }, + ) +} 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 4c538dee..de1c0ec9 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 @@ -20,15 +20,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelec import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.PinLockItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.alwaysOnVpnItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.appearanceItem -import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.killSwitchItem +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.* import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel @@ -63,7 +55,8 @@ fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { SurfaceSelectionGroupButton( items = buildList { - add(AppShortcutsItem(uiState, viewModel)) + add(tunnelMonitoringItem()) + add(appShortcutsItem(uiState, viewModel)) if (!isTv) add(alwaysOnVpnItem(uiState, viewModel)) add(killSwitchItem()) add(RestartAtBootItem(uiState, viewModel)) @@ -81,7 +74,7 @@ fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) { ) SectionDivider() if (!isTv) { - SurfaceSelectionGroupButton(items = listOf(KernelModeItem(uiState, viewModel))) + SurfaceSelectionGroupButton(items = listOf(kernelModeItem(uiState, viewModel))) SectionDivider() } SurfaceSelectionGroupButton( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AppShortcutItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appShortcutItem.kt similarity index 95% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AppShortcutItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appShortcutItem.kt index 21f3dc18..e1da1216 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/AppShortcutItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/appShortcutItem.kt @@ -15,7 +15,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun AppShortcutsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun appShortcutsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { return SelectionItem( leading = { Icon(Icons.Filled.AppShortcut, contentDescription = null) }, trailing = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/KernelModeItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/kernelModeItem.kt similarity index 96% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/KernelModeItem.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/kernelModeItem.kt index ed1400d0..5e29c2a6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/KernelModeItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/kernelModeItem.kt @@ -15,7 +15,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent @Composable -fun KernelModeItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { +fun kernelModeItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem { return SelectionItem( leading = { Icon(Icons.Outlined.Code, contentDescription = null) }, title = { 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 new file mode 100644 index 00000000..2eb6923b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/tunnelMonitoringItem.kt @@ -0,0 +1,31 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.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 + +@Composable +fun tunnelMonitoringItem(): SelectionItem { + val navController = LocalNavController.current + return SelectionItem( + leading = { Icon(Icons.Outlined.MonitorHeart, null) }, + trailing = { ForwardButton { navController.navigate(Route.TunnelMonitoring) } }, + title = { + Text( + text = stringResource(R.string.tunnel_monitoring), + style = + MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + onClick = { navController.navigate(Route.TunnelMonitoring) }, + ) +} 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 34225544..bdb6494b 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 @@ -6,14 +6,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState 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.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 7cc08666..a2b24db0 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 @@ -7,12 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FolderZip -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 new file mode 100644 index 00000000..86a6ceaa --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt @@ -0,0 +1,117 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring + +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.outlined.Replay +import androidx.compose.material.icons.outlined.Timer +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.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.button.surface.SurfaceSelectionGroupButton +import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledNumberDropdown +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 + +@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) + } + + 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(listOf(enablePingMonitoringItem(uiState, viewModel))) + if (uiState.appSettings.isPingEnabled) { + LabelledNumberDropdown( + title = { + Text( + text = stringResource(R.string.tunnel_ping_interval), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + ) + }, + leading = { Icon(Icons.Outlined.Timer, contentDescription = null) }, + currentValue = pingInterval, + onSelected = { selected -> + viewModel.handleEvent(AppEvent.SetPingInterval(selected!!)) + }, + options = (10..60).step(10).toList(), + ) + LabelledNumberDropdown( + title = { + Text( + text = stringResource(R.string.attempts_per_interval), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + ) + }, + leading = { Icon(Icons.Outlined.Replay, contentDescription = null) }, + currentValue = pingAttempts, + onSelected = { selected -> + viewModel.handleEvent(AppEvent.SetPingAttempts(selected!!)) + }, + options = (1..5).toList(), + ) + LabelledNumberDropdown( + title = { + Text( + text = stringResource(R.string.ping_timeout), + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + ) + }, + leading = { Icon(Icons.Outlined.TimerOff, contentDescription = null) }, + currentValue = pingTimeout, + description = { + Text( + text = stringResource(R.string.timeout_all_attempts), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.outline + ), + ) + }, + onSelected = { selected -> + viewModel.handleEvent(AppEvent.SetPingTimeout(selected)) + }, + options = (10..20).toList() + null, + ) + SurfaceSelectionGroupButton(listOf(detailedPingStatsItem(uiState, viewModel))) + } + } +} 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 new file mode 100644 index 00000000..9d8ab454 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/detailedPingStatsItem.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QueryStats +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.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 { + return SelectionItem( + leading = { Icon(Icons.Outlined.QueryStats, contentDescription = null) }, + title = { + Text( + text = stringResource(R.string.display_detailed_ping_stats), + style = + MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + trailing = { + ScaledSwitch( + checked = uiState.appState.showDetailedPingStats, + onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) }, + ) + }, + onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) }, + ) +} 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 new file mode 100644 index 00000000..61615637 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/components/enablePingMonitoringItem.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NetworkPing +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.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 { + return SelectionItem( + leading = { Icon(Icons.Outlined.NetworkPing, contentDescription = null) }, + trailing = { + ScaledSwitch( + checked = uiState.appSettings.isPingEnabled, + onClick = { viewModel.handleEvent(AppEvent.TogglePingMonitoring) }, + ) + }, + title = { + Text( + text = stringResource(R.string.monitoring_ping), + style = + MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + onClick = { viewModel.handleEvent(AppEvent.TogglePingMonitoring) }, + ) +} 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 6972f7f9..59be97d3 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 @@ -12,12 +12,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 73f02e99..6d35d713 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 @@ -1,9 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.components import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Balance -import androidx.compose.material.icons.filled.Book -import androidx.compose.material.icons.filled.Policy import androidx.compose.material.icons.outlined.Balance import androidx.compose.material.icons.outlined.Book import androidx.compose.material.icons.outlined.Policy diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateSection.kt index d3d161ee..6b79e904 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateSection.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/components/UpdateSection.kt @@ -2,9 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.components import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.outlined.CloudDownload -import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource 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 42022daf..53794720 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,12 +5,7 @@ import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 b1c204d8..d62f1309 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/Constants.kt @@ -27,9 +27,10 @@ object Constants { const val SUBSCRIPTION_TIMEOUT = 5_000L const val DEFAULT_PING_IP = "1.1.1.1" - const val PING_TIMEOUT = 5_000L - const val PING_INTERVAL = 60_000L - const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour + const val PING_TIMEOUT: Int = 5_000 + + const val PING_ATTEMPTS: Int = 3 + const val PING_INTERVAL = 30 val amProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4") const val QR_CODE_NAME_PROPERTY = "# Name =" 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 eb0517ce..3f3a535b 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 @@ -5,6 +5,8 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import java.math.BigDecimal import java.text.DecimalFormat +import kotlin.math.pow +import kotlin.math.roundToInt fun BigDecimal.toThreeDecimalPlaceString(): String { val df = DecimalFormat("#.###") @@ -28,3 +30,12 @@ fun MutableList.addAllUnique(elements: Collection, comparator: (T, T) fun > List.isSortedBy(selector: (T) -> R): Boolean { return zipWithNext().all { (a, b) -> selector(a) <= selector(b) } } + +fun Int.toMillis(): Long { + return this * 1_000L +} + +fun Double.round(decimals: Int): Double { + val factor = 10.0.pow(decimals) + return (this * factor).roundToInt() / factor +} 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 28fea67c..eb2708d1 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,17 +2,15 @@ package com.zaneschepke.wireguardautotunnel.util.extensions import androidx.compose.ui.graphics.Color import com.wireguard.android.backend.BackendException -import com.wireguard.config.Peer -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.HandshakeStatus import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics 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 java.net.InetAddress import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.config.Config @@ -40,18 +38,6 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus { } } -fun Peer.isReachable(): Boolean { - val host = - if (this.endpoint.isPresent) { - this.endpoint.get().host - } else { - Constants.DEFAULT_PING_IP - } - Timber.d("Checking reachability of peer: $host") - val reachable = InetAddress.getByName(host).isReachable(Constants.PING_TIMEOUT.toInt()) - return reachable -} - fun TunnelStatistics?.asColor(): Color { return this?.mapPeerStats() ?.map { it.value?.handshakeStatus() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt new file mode 100644 index 00000000..9164fee7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/NetworkUtils.kt @@ -0,0 +1,84 @@ +package com.zaneschepke.wireguardautotunnel.util.network + +import com.marsounjan.icmp4a.Icmp +import com.marsounjan.icmp4a.Icmp4a +import com.zaneschepke.wireguardautotunnel.util.extensions.round +import java.io.IOException +import java.time.Instant +import kotlin.math.sqrt +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import timber.log.Timber + +class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) { + + /** + * Performs a ping with stats, wrapped in a coroutine for async execution. Dynamically handles + * IPv4/ICMP or IPv6/ICMPv6 based on the host. + * + * @param host The host to ping (domain, IPv4, or IPv6 address). + * @param count Number of ping attempts. + * @param timeoutMillis Overall timeout in milliseconds for the entire operation. + * @return PingStats if successful, with isReachable set based on whether any packets were + * received, and lastSuccessfulPingMillis set to the approximate epoch millis of the last + * successful ping response. + * @throws IOException on failure (e.g., unknown host or other errors). + * @throws TimeoutCancellationException on timeout. + */ + suspend fun pingWithStats( + host: String, + count: Int, + timeoutMillis: Long = (count * 2000L), + ): PingStats { + return withTimeout(timeoutMillis) { + withContext(ioDispatcher) { + val icmp = Icmp4a() + val stats = PingStats() + val rttList = mutableListOf() + var received = 0 + var lastSuccessTime: Long? = null + + icmp + .pingInterval(host, count = count, intervalMillis = 500) + .onEach { status -> + when (val result = status.result) { + is Icmp.PingResult.Success -> { + received++ + rttList.add(result.ms.toDouble()) + lastSuccessTime = Instant.now().toEpochMilli() + } + is Icmp.PingResult.Failed -> { + Timber.w("Ping failed with result: ${result.message}") + } + } + } + .catch { + when (it) { + is CancellationException -> Timber.d("Ping completed") + else -> throw it + } + } + .collect() + + if (rttList.isNotEmpty()) { + stats.transmitted = count + stats.received = received + stats.packetLoss = ((count - received).toDouble().round(2) / count) * 100 + stats.rttMin = rttList.minOrNull()?.round(2) ?: 0.0 + stats.rttAvg = rttList.average().round(2) + stats.rttMax = rttList.maxOrNull()?.round(2) ?: 0.0 + val mean = stats.rttAvg + stats.rttStddev = + sqrt(rttList.map { (it - mean) * (it - mean) }.average()).round(2) + stats.isReachable = received > 0 + stats.lastSuccessfulPingMillis = lastSuccessTime + } else { + stats.isReachable = false + } + stats + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/PingStats.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/PingStats.kt new file mode 100644 index 00000000..94cbc7f8 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/network/PingStats.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.wireguardautotunnel.util.network + +data class PingStats( + var transmitted: Int = 0, + var received: Int = 0, + var packetLoss: Double = 0.0, // percentage + var rttMin: Double = 0.0, + var rttAvg: Double = 0.0, + var rttMax: Double = 0.0, + var rttStddev: Double = 0.0, + var isReachable: Boolean = false, + var lastSuccessfulPingMillis: Long? = null, +) { + fun handleOffline(): PingStats { + return copy( + transmitted = 0, + received = 0, + packetLoss = 0.0, + rttMin = 0.0, + rttAvg = 0.0, + rttMax = 0.0, + rttStddev = 0.0, + isReachable = false, + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt index 4ec3d3a5..4f7db5ac 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt @@ -10,7 +10,6 @@ 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.ConnectivityState import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel @@ -22,11 +21,11 @@ import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.MainDispatcher import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType +import com.zaneschepke.wireguardautotunnel.domain.events.BackendError 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.state.TunnelState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState import com.zaneschepke.wireguardautotunnel.ui.theme.Theme @@ -43,6 +42,7 @@ import java.util.* import javax.inject.Inject import javax.inject.Provider import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -65,11 +65,18 @@ constructor( private val logReader: LogReader, private val fileUtils: FileUtils, private val shortcutManager: ShortcutManager, - private val networkMonitor: NetworkMonitor, + networkMonitor: NetworkMonitor, ) : ViewModel() { private var logsJob: Job? = null + private val _eventFlow = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val tunnelMutex = Mutex() private val settingsMutex = Mutex() private val tunControlMutex = Mutex() @@ -89,19 +96,23 @@ constructor( val uiState: StateFlow = combine( - appDataRepository.settings.flow, - appDataRepository.tunnels.flow, - appDataRepository.appState.flow, - tunnelManager.activeTunnels, - serviceManager.autoTunnelService.map { it != null }, + 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 }, + ) { activeTunnels, autoTunnel -> + Pair(activeTunnels, autoTunnel) + }, networkMonitor.connectivityStateFlow, - ) { array -> - val settings = array[0] as AppSettings - val tunnels = array[1] as List - val appState = array[2] as AppState - val activeTunnels = array[3] as Map - val autoTunnel = array[4] as Boolean - val network = array[5] as ConnectivityState + ) { repoTriple, managerPair, network -> + val (settings, tunnels, appState) = repoTriple + val (activeTunnels, autoTunnel) = managerPair AppUiState( appSettings = settings, @@ -115,7 +126,7 @@ constructor( } .stateIn( viewModelScope + ioDispatcher, - SharingStarted.Companion.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), + SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), AppUiState(), ) @@ -126,38 +137,38 @@ constructor( handleKillSwitchChange(state.appSettings) initServicesFromSavedState(state) if (state.appState.isLocalLogsEnabled) logsJob = startCollectingLogs() - handleTunnelErrors() + handleTunnelMessages() } - } - } - - fun handleUiEvent(event: UiEvent): Job = - viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) } - - fun handleEvent(event: AppEvent): Job = - viewModelScope.launch(ioDispatcher) { - uiState.withFirstState { state -> + _eventFlow.collect { event -> + 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, state.tunnels) + 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() @@ -170,40 +181,47 @@ constructor( is AppEvent.SetTheme -> handleThemeChange(event.theme) is AppEvent.ToggleIpv4Preferred -> handleToggleIpv4(event.tunnel) is AppEvent.TogglePrimaryTunnel -> handleTogglePrimaryTunnel(event.tunnel) - is AppEvent.SetTunnelPingCooldown -> - handlePingCoolDownChange(event.tunnel, event.pingCooldown) - is AppEvent.SetTunnelPingInterval -> - handlePingIntervalChange(event.tunnel, event.pingInterval) 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.TogglePingTunnelEnabled -> handleTogglePingTunnel(event.tunnel) - is AppEvent.SetTunnelPingIp -> - handleTunnelPingIpChange(event.tunnelConf, event.ip) + 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 } @@ -211,11 +229,13 @@ constructor( 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 -> @@ -223,10 +243,63 @@ constructor( 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) + ) } } } + } + + fun handleUiEvent(event: UiEvent): Job = + viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) } + + fun handleEvent(event: AppEvent) { + _eventFlow.tryEmit(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) @@ -315,15 +388,26 @@ constructor( } } - private fun handleTunnelErrors() = + private fun handleTunnelMessages() = viewModelScope.launch { - tunnelManager.errorEvents.collect { errorEvent -> - handleShowMessage( - StringValue.StringResource( - R.string.tunnel_error_template, - errorEvent.second.toStringRes(), + launch { + tunnelManager.errorEvents.collect { errorEvent -> + handleShowMessage( + when (val event = errorEvent.second) { + is BackendError.BounceFailed -> event.toStringValue() + else -> + StringValue.StringResource( + R.string.tunnel_error_template, + errorEvent.second.toStringRes(), + ) + } ) - ) + } + } + launch { + tunnelManager.messageEvents.collect { messageEvent -> + handleShowMessage(messageEvent.second.toStringValue()) + } } } @@ -336,11 +420,11 @@ constructor( private fun handleSetBottomSheet(bottomSheet: AppViewState.BottomSheet) = _appViewState.update { it.copy(bottomSheet = bottomSheet) } - private suspend fun handleTunnelPingIpChange(tunnelConf: TunnelConf, ip: String) = - saveTunnel(tunnelConf.copy(pingIp = ip)) + private suspend fun handleTunnelPingTargetChange(tunnelConf: TunnelConf, target: String) = + saveTunnel(tunnelConf.copy(pingTarget = target)) private suspend fun handleTogglePingTunnel(tunnel: TunnelConf) = - saveTunnel(tunnel.copy(isPingEnabled = !tunnel.isPingEnabled)) + saveTunnel(tunnel.copy(restartOnPingFailure = !tunnel.restartOnPingFailure)) private suspend fun handleToggleLocalLogging(currentlyEnabled: Boolean) { loggerMutex.withLock { @@ -601,20 +685,6 @@ constructor( private suspend fun handleToggleIpv4(tunnelConf: TunnelConf) = saveTunnel(tunnelConf.copy(isIpv4Preferred = !tunnelConf.isIpv4Preferred)) - private suspend fun handlePingIntervalChange(tunnelConf: TunnelConf, interval: String) = - saveTunnel( - tunnelConf.copy( - pingInterval = if (interval.isBlank()) null else interval.toLong() * 1000 - ) - ) - - private suspend fun handlePingCoolDownChange(tunnelConf: TunnelConf, cooldown: String) = - saveTunnel( - tunnelConf.copy( - pingCooldown = if (cooldown.isBlank()) null else cooldown.toLong() * 1000 - ) - ) - private suspend fun handleThemeChange(theme: Theme) { appDataRepository.appState.setTheme(theme) } @@ -821,7 +891,7 @@ constructor( rootShell.get().start() handleShowMessage(StringValue.StringResource(R.string.root_accepted)) true - } catch (e: Exception) { + } catch (_: Exception) { handleShowMessage(StringValue.StringResource(R.string.error_root_denied)) false } 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 index 9e327593..4f447d9f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/event/AppEvent.kt @@ -29,17 +29,15 @@ sealed class 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 TogglePingTunnelEnabled(val tunnel: TunnelConf) : AppEvent() + data class ToggleRestartOnPingFailure(val tunnel: TunnelConf) : AppEvent() - data class SetTunnelPingInterval(val tunnel: TunnelConf, val pingInterval: String) : AppEvent() - - data class SetTunnelPingCooldown(val tunnel: TunnelConf, val pingCooldown: String) : AppEvent() - - data class SetTunnelPingIp(val tunnelConf: TunnelConf, val ip: String) : AppEvent() + data class SetTunnelPingTarget(val tunnelConf: TunnelConf, val host: String) : AppEvent() data class AddTunnelRunSSID(val ssid: String, val tunnel: TunnelConf) : AppEvent() @@ -79,6 +77,12 @@ sealed class AppEvent { data class SetTheme(val theme: Theme) : AppEvent() + data class SaveMonitoringSettings( + val pingInterval: Int, + val tunnelPingAttempts: Int, + val pingTimeout: Int?, + ) : AppEvent() + data class SetDetectionMethod(val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod) : AppEvent() @@ -127,4 +131,12 @@ sealed class 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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cf0deb5..667fff3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,13 +53,13 @@ Rotate keys Private key Copy public key - base64 key + Base64 key comma separated list Listen port (random) (optional) Pre-shared key - seconds + Seconds Persistent keepalive Cancel Authentication failed @@ -81,12 +81,12 @@ Delete tunnel Are you sure you would like to delete this tunnel? Yes - all + All No email app detected No browser detected Open an issue Read the logs - (auto) + (Auto) Pin is incorrect Pin successfully created Enter your pin @@ -129,9 +129,9 @@ "optional, default: " Ping restart cooldown (sec) Show Amnezia properties - never + Never sec - handshake + Handshake Logs Kill switch Appearance @@ -300,4 +300,31 @@ Start Auto-tunnel is running Auto-tunnel is not running + Tunnel restarted successfully by pinger. + Pinger restart failed. Tunnel recovered with old addresses. + Pinger failed to recovery tunnel with error: %1$s + Tunnel monitoring + Custom ping target (optional) + IP or hostname + Used if no internal IP is available (e.g., in full-tunnel configs). Leaving + blank defaults to Cloudflare. + + Tunnel ping interval + Ping attempts per interval + Tunnel ping timeout + Total timeout for all attempts + Display detailed ping stats + Enable ping tunnel monitoring + "RX: %1$s MB" + "TX: %1$s MB" + Reachable: %1$s + Last successful ping: %1$s + %1$s sec ago + Latency: %1$s + Jitter: %1$s + Packets sent: %1$s + Packet loss: %.2f%% + Ping target: %1$s + True + False diff --git a/buildSrc/src/main/kotlin/LocalProperties.kt b/buildSrc/src/main/kotlin/LocalProperties.kt index 21d07a84..7e4b8e57 100644 --- a/buildSrc/src/main/kotlin/LocalProperties.kt +++ b/buildSrc/src/main/kotlin/LocalProperties.kt @@ -1,6 +1,6 @@ import java.io.File import java.io.FileInputStream -import java.util.Properties +import java.util.* object LocalProperties { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62c67895..23a8afd0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ accompanist = "0.37.3" activityCompose = "1.10.1" amneziawgAndroid = "1.5.0" androidx-junit = "1.2.1" +icmp4a = "1.0.0" shizuku = "13.1.5" appcompat = "1.7.1" biometricKtx = "1.2.0-alpha05" @@ -66,6 +67,7 @@ androidx-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", v androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics", version.ref = "compose" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +icmp4a = { module = "com.marsounjan:icmp4a", version.ref = "icmp4a" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt index 81e7a1d6..3626664a 100644 --- a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt @@ -3,17 +3,11 @@ package com.zaneschepke.logcatter import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.zaneschepke.logcatter.model.LogMessage -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch class LogcatManager(pid: Int, logDir: String, maxFileSize: Long, maxFolderSize: Long) : LogReader, DefaultLifecycleObserver { diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ActiveWifiStateManager.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ActiveWifiStateManager.kt deleted file mode 100644 index e491a944..00000000 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/ActiveWifiStateManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.zaneschepke.networkmonitor - -import android.net.Network -import android.net.NetworkCapabilities -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update - -// keep track of the currently active network(s) -class ActiveWifiStateManager { - private val _stateFlow = - MutableStateFlow(linkedMapOf>()) - - @Synchronized - fun put(key: String, value: Pair) { - _stateFlow.update { currentMap -> - linkedMapOf(*currentMap.toList().toTypedArray()).apply { put(key, value) } - } - } - - @Synchronized - fun remove(key: String) { - _stateFlow.update { currentMap -> - linkedMapOf(*currentMap.toList().toTypedArray()).apply { remove(key) } - } - } - - fun isEmpty(): Boolean = _stateFlow.value.isEmpty() - - fun getLatestValue(): Pair? { - return _stateFlow.value.entries.lastOrNull()?.value - } - - @Synchronized - fun clear() { - _stateFlow.update { currentMap -> - linkedMapOf(*currentMap.toList().toTypedArray()).apply { clear() } - } - } -} diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt index b4aae4df..ce8a5978 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt @@ -17,9 +17,12 @@ import androidx.core.content.ContextCompat import com.wireguard.android.util.RootShell import com.zaneschepke.networkmonitor.shizuku.ShizukuShell import com.zaneschepke.networkmonitor.util.* -import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber class AndroidNetworkMonitor( @@ -64,7 +67,8 @@ class AndroidNetworkMonitor( appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager? // Track active Wi-Fi networks, their capabilities, and last active network ID - private val activeWifiNetworks = ActiveWifiStateManager() + private val activeWifiNetworks = + ConcurrentHashMap>() private val permissionsChangedFlow = MutableStateFlow(false) @@ -193,7 +197,7 @@ class AndroidNetworkMonitor( fun handleOnWifiAvailable(network: Network) { Timber.d("Wi-Fi onAvailable: network=$network") - activeWifiNetworks.put(network.toString(), Pair(network, null)) + activeWifiNetworks[network.toString()] = Pair(network, null) trySend(TransportEvent.Available(network, detectionMethod)) } @@ -202,7 +206,7 @@ class AndroidNetworkMonitor( networkCapabilities: NetworkCapabilities, ) { Timber.d("Wi-Fi onCapabilitiesChanged: network=$network") - activeWifiNetworks.put(network.toString(), Pair(network, networkCapabilities)) + activeWifiNetworks[network.toString()] = Pair(network, networkCapabilities) trySend( TransportEvent.CapabilitiesChanged(network, networkCapabilities, detectionMethod) ) @@ -418,7 +422,7 @@ class AndroidNetworkMonitor( .also { Timber.d("Connectivity Status: $it") } } .distinctUntilChanged() - .shareIn(applicationScope, SharingStarted.WhileSubscribed(5000), replay = 1) + .shareIn(applicationScope, SharingStarted.Eagerly, replay = 1) override fun checkPermissionsAndUpdateState() { val action = actionPermissionCheck