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