From 2cc71e657ba81337d1dcc6330eef805ac892564e Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Fri, 17 Oct 2025 02:30:45 -0400 Subject: [PATCH] fix: restart on boot, dynamic dns, auto tunnel reliability Separate settings for starting tunnels vs auto tunnel on boot, fixing logic to make behavior more expected. Fix a bug where dynamic DNS updater was only running once and not continually monitoring. Further improvements to prevent spurious network monitoring states. Improved reevaluate job to allow for reliable manual tunnel overrides while auto tunnel is active. Improved messaging on errors and dynamic dns events. --- .../25.json | 477 ++++++++++++++++++ .../core/broadcast/RestartReceiver.kt | 19 +- .../core/notification/NotificationMonitor.kt | 21 +- .../service/BaseTunnelForegroundService.kt | 1 - .../core/service/ServiceManager.kt | 8 +- .../service/autotunnel/AutoTunnelService.kt | 68 +-- .../core/service/autotunnel/StateChange.kt | 18 +- .../core/tunnel/TunnelManager.kt | 224 +++++--- .../core/tunnel/TunnelProvider.kt | 4 +- .../wireguardautotunnel/data/AppDatabase.kt | 3 +- .../data/DatabaseConverters.kt | 18 + .../data/entity/AutoTunnelSettings.kt | 1 + .../data/entity/GeneralSettings.kt | 2 + .../data/entity/TunnelConfig.kt | 2 +- .../data/mapper/AutoTunnelSettingsMapper.kt | 2 + .../data/mapper/SettingsMapper.kt | 2 + .../domain/events/BackendCoreException.kt | 12 +- .../domain/events/BackendMessage.kt | 7 +- .../domain/model/AutoTunnelSettings.kt | 1 + .../domain/model/GeneralSettings.kt | 1 + .../domain/state/AutoTunnelState.kt | 14 +- .../ui/screens/autotunnel/AutoTunnelScreen.kt | 13 + .../advanced/AutoTunnelAdvancedScreen.kt | 26 +- .../ui/screens/tunnels/TunnelsScreen.kt | 5 +- .../ui/sideeffect/LocalSideEffect.kt | 4 +- .../viewmodel/AutoTunnelViewModel.kt | 4 + .../viewmodel/SharedAppViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 3 + .../networkmonitor/AndroidNetworkMonitor.kt | 25 +- .../networkmonitor/shizuku/ShizukuShell.kt | 8 +- 30 files changed, 810 insertions(+), 192 deletions(-) create mode 100644 app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json new file mode 100644 index 00000000..2015788b --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/25.json @@ -0,0 +1,477 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "2ea437642cca24af74dc57904899909a", + "entities": [ + { + "tableName": "tunnel_config", + "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_tunnel_config_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tunnel_config_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "proxy_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `socks5_proxy_enabled` INTEGER NOT NULL DEFAULT 0, `socks5_proxy_bind_address` TEXT, `http_proxy_enable` INTEGER NOT NULL DEFAULT 0, `http_proxy_bind_address` TEXT, `proxy_username` TEXT, `proxy_password` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socks5ProxyEnabled", + "columnName": "socks5_proxy_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "socks5ProxyBindAddress", + "columnName": "socks5_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "httpProxyEnabled", + "columnName": "http_proxy_enable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "httpProxyBindAddress", + "columnName": "http_proxy_bind_address", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyUsername", + "columnName": "proxy_username", + "affinity": "TEXT" + }, + { + "fieldPath": "proxyPassword", + "columnName": "proxy_password", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "general_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT 0, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT 0, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_globals_enabled` INTEGER NOT NULL DEFAULT 0, `app_mode` INTEGER NOT NULL DEFAULT 0, `theme` TEXT NOT NULL DEFAULT 'AUTOMATIC', `locale` TEXT, `remote_key` TEXT, `is_remote_control_enabled` INTEGER NOT NULL DEFAULT 0, `is_pin_lock_enabled` INTEGER NOT NULL DEFAULT 0, `is_always_on_vpn_enabled` INTEGER NOT NULL DEFAULT 0, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT 0, `custom_split_packages` TEXT NOT NULL DEFAULT '{}')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShortcutsEnabled", + "columnName": "is_shortcuts_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isRestoreOnBootEnabled", + "columnName": "is_restore_on_boot_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMultiTunnelEnabled", + "columnName": "is_multi_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelGlobalsEnabled", + "columnName": "is_tunnel_globals_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "appMode", + "columnName": "app_mode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'AUTOMATIC'" + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteKey", + "columnName": "remote_key", + "affinity": "TEXT" + }, + { + "fieldPath": "isRemoteControlEnabled", + "columnName": "is_remote_control_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPinLockEnabled", + "columnName": "is_pin_lock_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isAlwaysOnVpnEnabled", + "columnName": "is_always_on_vpn_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isLanOnKillSwitchEnabled", + "columnName": "is_lan_on_kill_switch_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customSplitPackages", + "columnName": "custom_split_packages", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "auto_tunnel_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL DEFAULT 0, `trusted_network_ssids` TEXT NOT NULL DEFAULT '', `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL DEFAULT 0, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT 0, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT 0, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT 0, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT 0, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `start_on_boot` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAutoTunnelEnabled", + "columnName": "is_tunnel_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnMobileDataEnabled", + "columnName": "is_tunnel_on_mobile_data_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "trustedNetworkSSIDs", + "columnName": "trusted_network_ssids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isTunnelOnEthernetEnabled", + "columnName": "is_tunnel_on_ethernet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isTunnelOnWifiEnabled", + "columnName": "is_tunnel_on_wifi_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isWildcardsEnabled", + "columnName": "is_wildcards_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isStopOnNoInternetEnabled", + "columnName": "is_stop_on_no_internet_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "debounceDelaySeconds", + "columnName": "debounce_delay_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "isTunnelOnUnsecureEnabled", + "columnName": "is_tunnel_on_unsecure_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wifiDetectionMethod", + "columnName": "wifi_detection_method", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "startOnBoot", + "columnName": "start_on_boot", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "monitoring_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_ping_enabled` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT 1, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER, `show_detailed_ping_stats` INTEGER NOT NULL DEFAULT 0, `is_local_logs_enabled` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPingEnabled", + "columnName": "is_ping_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPingMonitoringEnabled", + "columnName": "is_ping_monitoring_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "tunnelPingIntervalSeconds", + "columnName": "tunnel_ping_interval_sec", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "30" + }, + { + "fieldPath": "tunnelPingAttempts", + "columnName": "tunnel_ping_attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "tunnelPingTimeoutSeconds", + "columnName": "tunnel_ping_timeout_sec", + "affinity": "INTEGER" + }, + { + "fieldPath": "showDetailedPingStats", + "columnName": "show_detailed_ping_stats", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isLocalLogsEnabled", + "columnName": "is_local_logs_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dns_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dns_protocol` INTEGER NOT NULL DEFAULT 0, `dns_endpoint` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dnsProtocol", + "columnName": "dns_protocol", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dnsEndpoint", + "columnName": "dns_endpoint", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ea437642cca24af74dc57904899909a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt index 4147bf53..1738ac8c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/broadcast/RestartReceiver.kt @@ -6,10 +6,8 @@ import android.content.Intent import com.zaneschepke.logcatter.LogReader import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.di.ApplicationScope -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -23,11 +21,20 @@ class RestartReceiver : BroadcastReceiver() { @Inject lateinit var logReader: LogReader - @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher - override fun onReceive(context: Context, intent: Intent) { Timber.d("RestartReceiver triggered with action: ${intent.action}") - if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) - applicationScope.launch(ioDispatcher) { logReader.deleteAndClearLogs() } + applicationScope.launch { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, + "android.intent.action.QUICKBOOT_POWERON", + "com.htc.intent.action.QUICKBOOT_POWERON" -> { + tunnelManager.handleReboot() + } + Intent.ACTION_MY_PACKAGE_REPLACED -> { + tunnelManager.handleRestore() + logReader.deleteAndClearLogs() + } + } + } } } 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 index d4d2a65b..91d24f58 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationMonitor.kt @@ -3,7 +3,6 @@ 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.BackendCoreException import com.zaneschepke.wireguardautotunnel.util.StringValue import jakarta.inject.Inject import kotlinx.coroutines.coroutineScope @@ -27,16 +26,14 @@ constructor( val notification = notificationManager.createNotification( WireGuardNotification.NotificationChannels.VPN, - title = StringValue.DynamicString(tunName), + title = + tunName?.let { StringValue.DynamicString(it) } + ?: StringValue.StringResource(R.string.tunnel), description = - when (error) { - is BackendCoreException.BounceFailed -> error.toStringValue() - else -> - StringValue.StringResource( - R.string.tunnel_error_template, - error.toStringRes(), - ) - }, + StringValue.StringResource( + R.string.tunnel_error_template, + error.toStringRes(), + ), groupKey = NotificationManager.VPN_GROUP_KEY, ) notificationManager.show( @@ -52,7 +49,9 @@ constructor( val notification = notificationManager.createNotification( WireGuardNotification.NotificationChannels.VPN, - title = StringValue.DynamicString(tunName), + title = + tunName?.let { StringValue.DynamicString(it) } + ?: StringValue.StringResource(R.string.tunnel), description = message.toStringValue(), groupKey = NotificationManager.VPN_GROUP_KEY, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt index f8b3714b..a7a805a0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt @@ -18,7 +18,6 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepos import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys import dagger.hilt.android.AndroidEntryPoint -import io.ktor.utils.io.ioDispatcher import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch 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 364528ad..6035a321 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 @@ -116,9 +116,11 @@ constructor( } private fun startServiceInternal() { - val intent = Intent(context, AutoTunnelService::class.java) - context.startForegroundService(intent) - context.bindService(intent, autoTunnelServiceConnection, Context.BIND_AUTO_CREATE) + if (autoTunnelService.value == null) { + val intent = Intent(context, AutoTunnelService::class.java) + context.startForegroundService(intent) + context.bindService(intent, autoTunnelServiceConnection, Context.BIND_AUTO_CREATE) + } } suspend fun startAutoTunnelService() = autoTunnelMutex.withLock { startServiceInternal() } 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 e2dd81e2..1005f2bd 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 @@ -129,14 +129,15 @@ class AutoTunnelService : LifecycleService() { debouncedConnectivityStateFlow .flowOn(ioDispatcher) .map(NetworkState::from) - .map { StateChange.NetworkChange(it) } + .map(::NetworkChange) .distinctUntilChanged() val settingsFlow = - combineSettings().map { StateChange.SettingsChange(it.first, it.second, it.third) } + combineSettings().map { (appMode, settings, tunnels) -> + SettingsChange(appMode, settings, tunnels) + } - val tunnelsFlow = - tunnelManager.activeTunnels.map { StateChange.ActiveTunnelsChange(it) } + val tunnelsFlow = tunnelManager.activeTunnels.map(::ActiveTunnelsChange) var reevaluationJob: Job? = null @@ -153,36 +154,46 @@ class AutoTunnelService : LifecycleService() { } .first() + val initialState = autoTunnelStateFlow.value + if (initialState != defaultState) { + handleAutoTunnelEvent( + initialState.determineAutoTunnelEvent(NetworkChange(initialState.networkState)) + ) + } + // use merge to limit the noise of a combine and also increase the scalability of auto // tunnel handling new states merge(networkFlow, settingsFlow, tunnelsFlow).collect { change -> - if (change !is StateChange.ActiveTunnelsChange) { + if (change !is ActiveTunnelsChange) { Timber.d("New state changed to ${change.javaClass.simpleName}") } + val previousState = autoTunnelStateFlow.value + when (change) { - is StateChange.NetworkChange -> { + is NetworkChange -> { + Timber.d("Network change: ${change.networkState}") 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") + if (previousState.networkState == change.networkState) { + Timber.d("Duplicate network state change detected, ignoring") return@collect } } - is StateChange.SettingsChange -> { + is SettingsChange -> { reevaluationJob?.cancel() autoTunnelStateFlow.update { it.copy(settings = change.settings, tunnels = change.tunnels) } + if ( + previousState.settings == change.settings && + previousState.tunnels == change.tunnels + ) { + Timber.d("Duplicate settings change detected, ignoring") + return@collect + } } - is StateChange.ActiveTunnelsChange -> { + is ActiveTunnelsChange -> { autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) } return@collect } @@ -191,25 +202,23 @@ class AutoTunnelService : LifecycleService() { handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change)) reevaluationJob = launch { + val snapshotNetwork = autoTunnelStateFlow.value.networkState delay(REEVALUATE_CHECK_DELAY) val currentState = autoTunnelStateFlow.value - if (currentState != defaultState) { - Timber.d("Re-evaluating auto-tunnel state..") + if ( + currentState != defaultState && currentState.networkState != snapshotNetwork + ) { + Timber.d( + "Re-evaluating auto-tunnel state.. (network changed since snapshot)" + ) handleAutoTunnelEvent(currentState.determineAutoTunnelEvent(change)) + } else { + Timber.d("Skipping re-eval: network unchanged or default state") } } } } - private fun isAndroidLateCellularActiveChange( - previous: NetworkState, - new: NetworkState, - ): Boolean { - return (previous.isWifiConnected != new.isWifiConnected && - previous.wifiName == new.wifiName && - previous.isMobileDataConnected != new.isMobileDataConnected) - } - private fun combineSettings(): Flow>> { return combine( settingsRepository.flow.map { it.appMode }.distinctUntilChanged(), @@ -351,7 +360,6 @@ class AutoTunnelService : LifecycleService() { } companion object { - // try to keep this window short as it will interrupt manual overrides - const val REEVALUATE_CHECK_DELAY = 2_000L + const val REEVALUATE_CHECK_DELAY = 3_000L } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt index e3eda8e3..91b4b283 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/autotunnel/StateChange.kt @@ -6,14 +6,14 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState -sealed class StateChange { - data class NetworkChange(val networkState: NetworkState) : StateChange() +sealed interface StateChange - data class SettingsChange( - val appMode: AppMode, - val settings: AutoTunnelSettings, - val tunnels: List, - ) : StateChange() +data class NetworkChange(val networkState: NetworkState) : StateChange - data class ActiveTunnelsChange(val activeTunnels: Map) : StateChange() -} +data class SettingsChange( + val appMode: AppMode, + val settings: AutoTunnelSettings, + val tunnels: List, +) : StateChange + +data class ActiveTunnelsChange(val activeTunnels: Map) : StateChange diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt index 19a62979..e159d0de 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 @@ -8,6 +8,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.events.BackendCoreException import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage +import com.zaneschepke.wireguardautotunnel.domain.model.AutoTunnelSettings import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.repository.AutoTunnelSettingsRepository @@ -44,9 +45,18 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : TunnelProvider { + @OptIn(ExperimentalCoroutinesApi::class) + private val localErrorEvents = MutableSharedFlow>() + + @OptIn(ExperimentalCoroutinesApi::class) + private val localMessageEvents = MutableSharedFlow>() + private val monitoringMutex = Mutex() private val monitoringJobs = ConcurrentHashMap() + private val ddnsMutex = Mutex() + private val ddnsJobs = ConcurrentHashMap() + private data class SideEffectState( val activeTuns: Map, val tuns: List, @@ -59,22 +69,6 @@ constructor( val condition: (SideEffectState) -> Boolean, ) - private val settings: StateFlow = - settingsRepository.flow - .filterNotNull() - .stateIn( - scope = applicationScope.plus(ioDispatcher), - started = SharingStarted.Eagerly, - initialValue = GeneralSettings(), - ) - - private val tunnels: StateFlow> = - tunnelsRepository.flow.stateIn( - scope = applicationScope.plus(ioDispatcher), - started = SharingStarted.Eagerly, - initialValue = emptyList(), - ) - private suspend fun getSettings(): GeneralSettings = settingsRepository.flow.filterNotNull().first { it != GeneralSettings() } @@ -139,7 +133,7 @@ constructor( Triple(activeTuns, tuns, settings) } } - .onStart { handleStateRestore() } + .onStart { handleRestore() } .onEach { (activeTuns, tuns, settings) -> val previouslyActive = activeTunsReference.exchange(activeTuns) val state = SideEffectState(activeTuns, tuns, settings, previouslyActive) @@ -168,16 +162,12 @@ constructor( s.activeTuns.size != s.previouslyActive.size }, ), - // TODO Not for kernel mode for now SideEffectWithCondition( effect = { s -> - handleTunnelMonitoringChanges(s.activeTuns, s.tuns) + handleDynamicDnsMonitoring(s.activeTuns, s.tuns, s.settings) }, condition = { s -> - s.tuns.any { - it.restartOnPingFailure && - s.activeTuns.keys.contains(it.id) - } && s.settings.appMode != AppMode.KERNEL + s.activeTuns.keys != s.previouslyActive.keys }, ), SideEffectWithCondition( @@ -213,19 +203,17 @@ constructor( } @OptIn(ExperimentalCoroutinesApi::class) - override val errorEvents: SharedFlow> = - tunnelProviderFlow - .flatMapLatest { it.errorEvents } + override val errorEvents: SharedFlow> = + merge(localErrorEvents, tunnelProviderFlow.flatMapLatest { it.errorEvents }) .shareIn( - scope = applicationScope.plus(ioDispatcher), + scope = applicationScope + ioDispatcher, started = SharingStarted.Eagerly, replay = 0, ) @OptIn(ExperimentalCoroutinesApi::class) - override val messageEvents: SharedFlow> = - tunnelProviderFlow - .flatMapLatest { it.messageEvents } + override val messageEvents: SharedFlow> = + merge(localMessageEvents, tunnelProviderFlow.flatMapLatest { it.messageEvents }) .shareIn( scope = applicationScope.plus(ioDispatcher), started = SharingStarted.Eagerly, @@ -317,13 +305,13 @@ constructor( private fun handleLockDownModeInit(withLanBypass: Boolean) { val allowedIps = if (withLanBypass) TunnelConfig.IPV4_PUBLIC_NETWORKS else emptySet() try { - // TODO handle situation where they don't have vpn permission, request it if (serviceManager.hasVpnPermission()) { proxyUserspaceTunnel.setBackendMode(BackendMode.KillSwitch(allowedIps)) + } else { + throw BackendCoreException.NotAuthorized } } catch (e: BackendCoreException) { - // TODO expose this error to user - Timber.e(e) + localErrorEvents.tryEmit(null to e) } } @@ -337,61 +325,59 @@ constructor( proxyUserspaceTunnel.setBackendMode(BackendMode.Inactive) } - // TODO refactor this - private suspend fun handleStateRestore() { - val settings = settingsRepository.flow.first() - val autoTunnelSettings = autoTunnelSettingsRepository.flow.first() - if (settings.isRestoreOnBootEnabled) { - if (autoTunnelSettings.isAutoTunnelEnabled) { - tunnelsRepository.resetActiveTunnels() - return autoTunnelSettingsRepository.updateAutoTunnelEnabled(true) - } - val tunnels = tunnelsRepository.flow.first() - when (settings.appMode) { - // TODO eventually, lockdown/proxy can support multi + suspend fun handleRestore() = + withContext(ioDispatcher) { + val settings = settingsRepository.getGeneralSettings() + val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings() + val tunnels = tunnelsRepository.getAll() + if (autoTunnelSettings.isAutoTunnelEnabled) + return@withContext restoreAutoTunnel(autoTunnelSettings) + when (val mode = settings.appMode) { AppMode.VPN, - AppMode.LOCK_DOWN, - AppMode.PROXY -> - tunnels - .firstOrNull { it.isActive } - ?.let { - // clear any duplicates - tunnelsRepository.resetActiveTunnels() - startTunnel(it) - } - // kernel supports multi + AppMode.PROXY, + AppMode.LOCK_DOWN -> { + if (serviceManager.hasVpnPermission()) { + if (mode == AppMode.LOCK_DOWN) + handleLockDownModeInit(settings.isLanOnKillSwitchEnabled) + tunnels.firstOrNull { it.isActive }?.let { startTunnel(it) } + } else localErrorEvents.emit(null to BackendCoreException.NotAuthorized) + } AppMode.KERNEL -> tunnels.filter { it.isActive }.forEach { conf -> startTunnel(conf) } } } + + private suspend fun restoreAutoTunnel(autoTunnelSettings: AutoTunnelSettings) { + autoTunnelSettingsRepository.upsert(autoTunnelSettings.copy(isAutoTunnelEnabled = true)) + serviceManager.startAutoTunnelService() } - private suspend fun handleTunnelMonitoringChanges( - activeTuns: Map, - configs: List, - ) { - configs - .filter { it.restartOnPingFailure && activeTuns.keys.contains(it.id) } - .forEach { conf -> - val tunState = activeTuns[conf.id] ?: return@forEach - if (tunState.health() == TunnelState.Health.UNHEALTHY) { - runCatching { - val updated = handleDnsReresolve(conf) - // TODO user messages - if (updated) { - Timber.i("Successfully update the peer endpoint to new address.") - } else { - Timber.i("Current endpoint address is already up to date.") - } - } - .onFailure { - Timber.e(it, "Failed to handle dns re-resolution for ${conf.name}") - } - // TODO backoff - delay(30_000L) + suspend fun handleReboot() = + withContext(ioDispatcher) { + val settings = settingsRepository.getGeneralSettings() + val autoTunnelSettings = autoTunnelSettingsRepository.getAutoTunnelSettings() + val defaultTunnel = tunnelsRepository.getStartTunnel() + if (autoTunnelSettings.startOnBoot) + return@withContext restoreAutoTunnel(autoTunnelSettings) + if (settings.isRestoreOnBootEnabled) { + tunnelsRepository.resetActiveTunnels() + when (val mode = settings.appMode) { + AppMode.VPN, + AppMode.PROXY, + AppMode.LOCK_DOWN -> { + if (serviceManager.hasVpnPermission()) { + if (mode == AppMode.LOCK_DOWN) + handleLockDownModeInit(settings.isLanOnKillSwitchEnabled) + } else + return@withContext localErrorEvents.emit( + null to BackendCoreException.NotAuthorized + ) + } + AppMode.KERNEL -> Unit } + defaultTunnel?.let { startTunnel(it) } } - } + } private suspend fun handleTunnelsActiveChange( previousActiveTuns: Map, @@ -423,6 +409,79 @@ constructor( } } + private suspend fun handleDynamicDnsMonitoring( + activeTuns: Map, + configs: List, + settings: GeneralSettings, + ) = + ddnsMutex.withLock { + val activeIds = + activeTuns.keys + .filter { id -> + configs.find { it.id == id }?.restartOnPingFailure == true && + settings.appMode != AppMode.KERNEL + } + .toSet() + + val currentJobs = ddnsJobs.keys.toSet() + val obsoleteIds = currentJobs - activeIds + + Timber.d( + "DDNS Monitoring: Active IDs: $activeIds, Obsolete IDs: $obsoleteIds, Total jobs before: ${ddnsJobs.size}" + ) + + obsoleteIds.forEach { id -> + ddnsJobs[id]?.cancel() + ddnsJobs.remove(id) + } + + activeIds.forEach { id -> + if (ddnsJobs.containsKey(id)) return@forEach // Skip if already monitored + val conf = configs.find { it.id == id } ?: return@forEach + val tunStateFlow = + activeTunnels.map { it[id] }.stateIn(applicationScope + ioDispatcher) + + val newJob = + applicationScope.launch(ioDispatcher) { + var backoff = 30_000L + while (isActive) { + val state = tunStateFlow.value ?: break + if (state.health() != TunnelState.Health.UNHEALTHY) { + backoff = BASE_BACKOFF + tunStateFlow.first { + it?.health() == TunnelState.Health.UNHEALTHY || it == null + } + continue + } + + runCatching { + val updated = handleDnsReresolve(conf) + if (updated) { + localMessageEvents.emit( + conf.name to BackendMessage.DynamicDnsSuccess + ) + backoff = BASE_BACKOFF + } else { + Timber.i( + "Dynamic DNS check completed, current endpoint address is already up to date." + ) + } + } + .onFailure { + Timber.e( + it, + "Failed to handle dns re-resolution for ${conf.name}", + ) + } + + delay(backoff) + backoff = (backoff * 1.5).toLong().coerceAtMost(MAX_BACKOFF_TIME) + } + } + ddnsJobs[id] = newJob + } + } + private suspend fun handleFullTunnelMonitoring( activeTuns: Map, configs: List, @@ -454,7 +513,7 @@ constructor( withLogs = settings.appMode != AppMode.KERNEL, tunStateFlow = tunStateFlow, getStatistics = { tunnelId -> getStatistics(tunnelId) }, - updateTunnelStatus = { tid, status, stats, pings, logHealth -> + updateTunnelStatus = { tid, _, stats, pings, logHealth -> updateTunnelStatus(tid, null, stats, pings, logHealth) }, ) @@ -462,4 +521,9 @@ constructor( monitoringJobs[id] = newJob } } + + companion object { + const val BASE_BACKOFF = 30_000L + const val MAX_BACKOFF_TIME = 300_000L + } } 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 064da113..3da43620 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 @@ -40,9 +40,9 @@ interface TunnelProvider { val activeTunnels: StateFlow> - val errorEvents: SharedFlow> + val errorEvents: SharedFlow> - val messageEvents: SharedFlow> + val messageEvents: SharedFlow> suspend fun updateTunnelStatus( tunnelId: Int, 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 ef26fea0..5a047af9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/AppDatabase.kt @@ -16,7 +16,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* MonitoringSettings::class, DnsSettings::class, ], - version = 24, + version = 25, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -41,6 +41,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.* AutoMigration(from = 20, to = 21, spec = FixProxySettingsMigration::class), AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23), + AutoMigration(from = 24, to = 25), ], exportSchema = true, ) 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 082d6cf9..5bb0ddea 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DatabaseConverters.kt @@ -24,6 +24,24 @@ class DatabaseConverters { } } + @TypeConverter + fun mapToString(map: Map): String { + return Json.encodeToString(map) + } + + @TypeConverter + fun stringToMap(json: String): Map { + return if (json.isEmpty() || json == "{}") { + emptyMap() + } else { + try { + Json.decodeFromString>(json) + } catch (_: Exception) { + emptyMap() + } + } + } + @TypeConverter fun setToString(value: Set): String { return listToString(value.toList()) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt index 83d528ea..c5126897 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/AutoTunnelSettings.kt @@ -28,4 +28,5 @@ data class AutoTunnelSettings( val isTunnelOnUnsecureEnabled: Boolean = false, @ColumnInfo(name = "wifi_detection_method", defaultValue = "0") val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), + @ColumnInfo(name = "start_on_boot", defaultValue = "0") val startOnBoot: Boolean = false, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt index e7c29ecd..8b7d56f3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/entity/GeneralSettings.kt @@ -28,4 +28,6 @@ data class GeneralSettings( val isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "is_lan_on_kill_switch_enabled", defaultValue = "0") val isLanOnKillSwitchEnabled: Boolean = false, + @ColumnInfo(name = "custom_split_packages", defaultValue = "{}") + val customSplitPackages: Map = emptyMap(), ) 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 2b3249f5..a56a44b6 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 @@ -27,7 +27,7 @@ data class TunnelConfig( val isIpv4Preferred: Boolean = true, @ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0, @ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]") - val autoTunnelApps: Set = setOf(), + val autoTunnelApps: Set = emptySet(), ) { companion object { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt index e0e216a1..e045ce8e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/mapper/AutoTunnelSettingsMapper.kt @@ -16,6 +16,7 @@ fun Entity.toDomain(): Domain = debounceDelaySeconds = debounceDelaySeconds, isTunnelOnUnsecureEnabled = isTunnelOnUnsecureEnabled, wifiDetectionMethod = wifiDetectionMethod, + startOnBoot = startOnBoot, ) fun Domain.toEntity(): Entity = @@ -31,4 +32,5 @@ fun Domain.toEntity(): Entity = debounceDelaySeconds = debounceDelaySeconds, isTunnelOnUnsecureEnabled = isTunnelOnUnsecureEnabled, wifiDetectionMethod = wifiDetectionMethod, + startOnBoot = startOnBoot, ) 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 fc5c5eaa..7de6fe9b 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 @@ -19,6 +19,7 @@ fun Entity.toDomain(): Domain = isPinLockEnabled = isPinLockEnabled, isAlwaysOnVpnEnabled = isAlwaysOnVpnEnabled, isLanOnKillSwitchEnabled = isLanOnKillSwitchEnabled, + customSplitPackages = customSplitPackages, ) fun Domain.toEntity(): Entity = @@ -36,4 +37,5 @@ fun Domain.toEntity(): Entity = isPinLockEnabled = isPinLockEnabled, isAlwaysOnVpnEnabled = isAlwaysOnVpnEnabled, isLanOnKillSwitchEnabled = isLanOnKillSwitchEnabled, + customSplitPackages = customSplitPackages, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendCoreException.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendCoreException.kt index 3b9e5b33..c1a0af36 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendCoreException.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendCoreException.kt @@ -22,8 +22,6 @@ sealed class BackendCoreException : Exception() { data object UapiUpdateFailed : BackendCoreException() - data class BounceFailed(val error: BackendCoreException) : BackendCoreException() - fun toStringRes() = when (this) { Config -> R.string.config_error @@ -34,18 +32,10 @@ sealed class BackendCoreException : 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 UapiUpdateFailed -> R.string.active_tunnel_update_failed } fun toStringValue(): StringValue { - return when (val backendError = this) { - is BounceFailed -> - StringValue.StringResource( - backendError.toStringRes(), - backendError.error.toStringRes(), - ) - else -> StringValue.StringResource(backendError.toStringRes()) - } + return StringValue.StringResource(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 index b5d4e74c..92ee21b3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/events/BackendMessage.kt @@ -5,14 +5,11 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue sealed class BackendMessage { - data object BounceSuccess : BackendMessage() - - data object BounceRecovery : BackendMessage() + data object DynamicDnsSuccess : BackendMessage() fun toStringRes() = when (this) { - BounceRecovery -> R.string.pinger_bounce_recovery - BounceSuccess -> R.string.pinger_bounce_successful + DynamicDnsSuccess -> R.string.ddns_success_message } fun toStringValue() = StringValue.StringResource(this.toStringRes()) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt index b5efc17f..dba4a72f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/AutoTunnelSettings.kt @@ -14,4 +14,5 @@ data class AutoTunnelSettings( val debounceDelaySeconds: Int = 3, val isTunnelOnUnsecureEnabled: Boolean = false, val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0), + val startOnBoot: Boolean = false, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt index 2a26b31f..624832a1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/model/GeneralSettings.kt @@ -17,4 +17,5 @@ data class GeneralSettings( val isPinLockEnabled: Boolean = false, val isAlwaysOnVpnEnabled: Boolean = false, val isLanOnKillSwitchEnabled: Boolean = false, + val customSplitPackages: Map = emptyMap(), ) 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 0d45de5d..33c25d14 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,5 +1,8 @@ package com.zaneschepke.wireguardautotunnel.domain.state +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.ActiveTunnelsChange +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.NetworkChange +import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.SettingsChange import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent @@ -19,8 +22,8 @@ data class AutoTunnelState( fun determineAutoTunnelEvent(stateChange: StateChange): AutoTunnelEvent { when (stateChange) { - is StateChange.NetworkChange, - is StateChange.SettingsChange -> { + is NetworkChange, + is SettingsChange -> { // Compute desired tunnel based on network conditions var desiredTunnel: TunnelConfig? = null if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) { @@ -44,21 +47,16 @@ data class AutoTunnelState( // Handle tunnel start/stop/change if (desiredTunnel != null) { if (currentTunnel != desiredTunnel.id) { - // 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 } } } - is StateChange.ActiveTunnelsChange -> Unit + is ActiveTunnelsChange -> Unit } return DoNothing } 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 991de1b7..d1cc92d2 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 @@ -319,6 +319,19 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { ) }, ) + SurfaceRow( + leading = { Icon(Icons.Outlined.RestartAlt, contentDescription = null) }, + title = stringResource(R.string.restart_at_boot), + trailing = { + ScaledSwitch( + checked = autoTunnelState.autoTunnelSettings.startOnBoot, + onClick = { viewModel.setStartAtBoot(it) }, + ) + }, + onClick = { + viewModel.setStartAtBoot(!autoTunnelState.autoTunnelSettings.startOnBoot) + }, + ) SurfaceRow( leading = { Icon(Icons.Outlined.Settings, contentDescription = null) }, title = stringResource(R.string.advanced_settings), 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 128f72e5..6960a8a9 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 @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced 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 @@ -18,6 +19,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.common.text.DescriptionText import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable @@ -28,13 +31,20 @@ fun AutoTunnelAdvancedScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top), modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), ) { - LabelledDropdown( - title = stringResource(R.string.debounce_delay), - leading = { Icon(Icons.Outlined.PauseCircle, null) }, - onSelected = { selected -> viewModel.setDebounceDelay(selected!!) }, - options = (0..10).toList(), - currentValue = autoTunnelState.autoTunnelSettings.debounceDelaySeconds, - optionToString = { it?.toString() ?: stringResource(R.string._default) }, - ) + Column { + GroupLabel( + stringResource(R.string.reliability), + modifier = Modifier.padding(horizontal = 16.dp), + ) + LabelledDropdown( + title = stringResource(R.string.debounce_delay), + description = { DescriptionText(stringResource(R.string.debounce_description)) }, + leading = { Icon(Icons.Outlined.PauseCircle, null) }, + onSelected = { selected -> viewModel.setDebounceDelay(selected!!) }, + options = (0..10).toList(), + currentValue = autoTunnelState.autoTunnelSettings.debounceDelaySeconds, + optionToString = { it?.toString() ?: stringResource(R.string._default) }, + ) + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt index 8fa1bc7e..adce377b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt @@ -4,8 +4,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt index 9a2898dc..1801f8cf 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt @@ -11,8 +11,6 @@ sealed class LocalSideEffect { data object ExportTunnels : Sheet() - data object BackupApp : Sheet() - data object LoggerActions : Sheet() } @@ -20,6 +18,8 @@ sealed class LocalSideEffect { data object QR : Modal() data object DeleteTunnels : Modal() + + data object AddCustomApp : Modal() } sealed class SelectedTunnels : LocalSideEffect() { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt index 27f443e7..42fb7383 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt @@ -120,6 +120,10 @@ constructor( autoTunnelRepository.upsert(state.autoTunnelSettings.copy(isTunnelOnEthernetEnabled = to)) } + fun setStartAtBoot(to: Boolean) = intent { + autoTunnelRepository.upsert(state.autoTunnelSettings.copy(startOnBoot = to)) + } + fun setDebounceDelay(to: Int) = intent { autoTunnelRepository.upsert(state.autoTunnelSettings.copy(debounceDelaySeconds = to)) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index 5c5d349a..2b75cafc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -9,11 +9,7 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.* import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState @@ -32,9 +28,6 @@ import java.io.IOException import java.net.URL import java.time.Instant import javax.inject.Inject -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.map import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54ebfea6..fa0bb9f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,4 +407,7 @@ Wi-Fi Text is too large to display QR. Auto-tunnel and tunnels must be disabled to perform this action. + Dynamic DNS endpoint successfully updated. + Reliability + Delay between rapid changes diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt index 932e6bf7..a70627e1 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/AndroidNetworkMonitor.kt @@ -17,13 +17,13 @@ import androidx.core.content.ContextCompat import com.wireguard.android.util.RootShell import com.zaneschepke.networkmonitor.shizuku.ShizukuShell import com.zaneschepke.networkmonitor.util.* +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 -import java.util.concurrent.ConcurrentHashMap class AndroidNetworkMonitor( private val appContext: Context, @@ -323,6 +323,13 @@ class AndroidNetworkMonitor( .also { Timber.d("Current SSID via ${method.name}: $it") } } + // prevent false positive late mobile data changes to combat android api quirks + private fun isLateCellularChange(previous: ConnectivityState, new: ConnectivityState): Boolean { + return (previous.wifiState.connected != new.wifiState.connected && + previous.wifiState.ssid == new.wifiState.ssid && + previous.cellularConnected != new.cellularConnected) + } + override val connectivityStateFlow: SharedFlow = combine( wifiFlow.scan( @@ -377,6 +384,22 @@ class AndroidNetworkMonitor( ) .also { Timber.d("Connectivity Status: $it") } } + .scan( + ConnectivityState( + WifiState( + locationPermissionsGranted = hasRequiredLocationPermissions(), + locationServicesEnabled = + locationManager?.isLocationServicesEnabled() ?: false, + ) + ) + ) { previous, current -> + if (isLateCellularChange(previous, current)) { + Timber.d("Skipping late cellular change") + previous + } else { + current + } + } .distinctUntilChanged() .shareIn(applicationScope, SharingStarted.Eagerly, replay = 1) diff --git a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt index 8ad0cd77..1d777ff3 100644 --- a/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt +++ b/networkmonitor/src/main/java/com/zaneschepke/networkmonitor/shizuku/ShizukuShell.kt @@ -1,6 +1,10 @@ package com.zaneschepke.networkmonitor.shizuku import android.os.ParcelFileDescriptor +import java.io.BufferedReader +import java.io.FileInputStream +import java.io.InputStreamReader +import kotlin.coroutines.resumeWithException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -8,10 +12,6 @@ import moe.shizuku.server.IRemoteProcess import moe.shizuku.server.IShizukuService import rikka.shizuku.Shizuku import timber.log.Timber -import java.io.BufferedReader -import java.io.FileInputStream -import java.io.InputStreamReader -import kotlin.coroutines.resumeWithException class ShizukuShell(private val applicationScope: CoroutineScope) { interface CommandResultListener {