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 {