diff --git a/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/10.json b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/10.json new file mode 100644 index 0000000..4907030 --- /dev/null +++ b/app/schemas/com.zaneschepke.wireguardautotunnel.data.AppDatabase/10.json @@ -0,0 +1,225 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "c8621055524f90b4d1972f6171f59e80", + "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_auto_tunnel_paused` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false)", + "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": "isAutoTunnelPaused", + "columnName": "is_auto_tunnel_paused", + "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" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null)", + "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": "isPingEnabled", + "columnName": "is_ping_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "pingInterval", + "columnName": "ping_interval", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "null" + }, + { + "fieldPath": "pingCooldown", + "columnName": "ping_cooldown", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "null" + }, + { + "fieldPath": "pingIp", + "columnName": "ping_ip", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "null" + } + ], + "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`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "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, 'c8621055524f90b4d1972f6171f59e80')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d8c8cc1..4dc121b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -103,12 +103,6 @@ android:launchMode="singleInstance" android:theme="@android:style/Theme.NoDisplay" /> - removeFromDataStore(key: Preferences.Key) { + withContext(ioDispatcher) { + try { + context.dataStore.edit { it.remove(key) } + } catch (e: IOException) { + Timber.e(e) + } catch (e: Exception) { + Timber.e(e) + } + } + } + fun getFromStoreFlow(key: Preferences.Key) = context.dataStore.data.map { it[key] } suspend fun getFromStore(key: Preferences.Key): T? { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt index c225b2d..4a38a76 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt @@ -37,11 +37,28 @@ data class TunnelConfig( 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, ) { companion object { - fun findDefault(tunnels: List): TunnelConfig? { - return tunnels.find { it.isPrimaryTunnel } ?: tunnels.firstOrNull() - } fun configFromWgQuick(wgQuick: String): Config { val inputStream: InputStream = wgQuick.byteInputStream() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt index 521a9d4..c023f0e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppDataRoomRepository.kt @@ -15,8 +15,9 @@ constructor( } override suspend fun getStartTunnelConfig(): TunnelConfig? { - return appState.getLastActiveTunnelId()?.let { - tunnels.getById(it) - } ?: getPrimaryOrFirstTunnel() + tunnels.getActive().let { + if (it.isNotEmpty()) return it.first() + return getPrimaryOrFirstTunnel() + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppStateRepository.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppStateRepository.kt index 195bed4..dc36609 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppStateRepository.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/repository/AppStateRepository.kt @@ -16,10 +16,6 @@ interface AppStateRepository { suspend fun setBatteryOptimizationDisableShown(shown: Boolean) - suspend fun getLastActiveTunnelId(): Int? - - suspend fun setLastActiveTunnelId(id: Int) - suspend fun getCurrentSsid(): String? suspend fun setCurrentSsid(ssid: String) 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 18ce1e9..3374969 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 @@ -47,14 +47,6 @@ class DataStoreAppStateRepository( withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) } } - override suspend fun getLastActiveTunnelId(): Int? { - return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.LAST_ACTIVE_TUNNEL) } - } - - override suspend fun setLastActiveTunnelId(id: Int) { - return withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.LAST_ACTIVE_TUNNEL, id) } - } - override suspend fun getCurrentSsid(): String? { return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) } } @@ -77,7 +69,6 @@ class DataStoreAppStateRepository( isPinLockEnabled = pref[DataStoreManager.IS_PIN_LOCK_ENABLED] ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT, - lastActiveTunnelId = pref[DataStoreManager.LAST_ACTIVE_TUNNEL], ) } catch (e: IllegalArgumentException) { Timber.e(e) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/NavigationModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/NavigationModule.kt new file mode 100644 index 0000000..862781e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/NavigationModule.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.wireguardautotunnel.module + +import android.content.Context +import androidx.navigation.NavHostController +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.NavigationService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @ActivityRetainedScoped + fun provideNestedNavController(@ApplicationContext context: Context): NavHostController { + return NavigationService(context).navController + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt index 8d55e05..bb9eb27 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt @@ -4,11 +4,8 @@ import android.content.Context import com.wireguard.android.backend.Backend import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.RootTunnelActionHandler -import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell -import com.wireguard.android.util.ToolsInstaller import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import dagger.Module @@ -46,8 +43,8 @@ class TunnelModule { @Provides @Singleton @Kernel - fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend { - return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell), RootTunnelActionHandler(rootShell)) + fun provideKernelBackend(@ApplicationContext context: Context, rootShell: org.amnezia.awg.util.RootShell): org.amnezia.awg.backend.Backend { + return org.amnezia.awg.backend.AwgQuickBackend(context, rootShell, org.amnezia.awg.util.ToolsInstaller(context, rootShell)) } @Provides @@ -61,7 +58,7 @@ class TunnelModule { fun provideVpnService( amneziaBackend: Provider, @Userspace userspaceBackend: Provider, - @Kernel kernelBackend: Provider, + @Kernel kernelBackend: Provider, appDataRepository: AppDataRepository, @ApplicationScope applicationScope: CoroutineScope, @IoDispatcher ioDispatcher: CoroutineDispatcher, @@ -75,10 +72,4 @@ class TunnelModule { ioDispatcher, ) } - - @Provides - @Singleton - fun provideServiceManager(): ServiceManager { - return ServiceManager() - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/AppUpdateReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/AppUpdateReceiver.kt index e9a17f6..b60e9a4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/AppUpdateReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/AppUpdateReceiver.kt @@ -24,9 +24,6 @@ class AppUpdateReceiver : BroadcastReceiver() { @Inject lateinit var appDataRepository: AppDataRepository - @Inject - lateinit var serviceManager: ServiceManager - @Inject lateinit var tunnelService: TunnelService @@ -36,7 +33,7 @@ class AppUpdateReceiver : BroadcastReceiver() { val settings = appDataRepository.settings.getSettings() if (settings.isAutoTunnelEnabled) { Timber.i("Restarting services after upgrade") - serviceManager.startWatcherServiceForeground(context) + ServiceManager.startWatcherServiceForeground(context) } if (!settings.isAutoTunnelEnabled || settings.isAutoTunnelPaused) { val tunnels = appDataRepository.tunnels.getAll().filter { it.isActive } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BackgroundActionReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BackgroundActionReceiver.kt index 700c4e8..aea9c42 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BackgroundActionReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BackgroundActionReceiver.kt @@ -27,9 +27,6 @@ class BackgroundActionReceiver : BroadcastReceiver() { @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository - @Inject - lateinit var serviceManager: ServiceManager - override fun onReceive(context: Context, intent: Intent) { val id = intent.getIntExtra(TUNNEL_ID_EXTRA_KEY, 0) if (id == 0) return @@ -39,7 +36,7 @@ class BackgroundActionReceiver : BroadcastReceiver() { applicationScope.launch { val tunnel = tunnelConfigRepository.getById(id) tunnel?.let { - serviceManager.startTunnelBackgroundService(context) + ServiceManager.startTunnelBackgroundService(context) tunnelService.get().startTunnel(it) } } @@ -48,7 +45,7 @@ class BackgroundActionReceiver : BroadcastReceiver() { applicationScope.launch { val tunnel = tunnelConfigRepository.getById(id) tunnel?.let { - serviceManager.stopTunnelBackgroundService(context) + ServiceManager.stopTunnelBackgroundService(context) tunnelService.get().stopTunnel(it) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt index 592d490..9a12b63 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -23,9 +23,6 @@ class BootReceiver : BroadcastReceiver() { @Inject lateinit var tunnelService: Provider - @Inject - lateinit var serviceManager: ServiceManager - @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope @@ -33,16 +30,18 @@ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_BOOT_COMPLETED != intent.action) return applicationScope.launch { - val settings = appDataRepository.settings.getSettings() - if (settings.isRestoreOnBootEnabled) { - appDataRepository.getStartTunnelConfig()?.let { - context.startTunnelBackground(it.id) + with(appDataRepository.settings.getSettings()) { + if (isRestoreOnBootEnabled) { + val activeTunnels = appDataRepository.tunnels.getActive() + if (activeTunnels.isNotEmpty()) { + context.startTunnelBackground(activeTunnels.first().id) + } + if (isAutoTunnelEnabled) { + Timber.i("Starting watcher service from boot") + ServiceManager.startWatcherServiceForeground(context) + } } } - if (settings.isAutoTunnelEnabled) { - Timber.i("Starting watcher service from boot") - serviceManager.startWatcherServiceForeground(context) - } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelService.kt index 9f98b37..10e0432 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelService.kt @@ -1,11 +1,14 @@ package com.zaneschepke.wireguardautotunnel.service.foreground import android.content.Context -import android.os.Bundle +import android.content.Intent +import android.os.IBinder import android.os.PowerManager import androidx.core.app.ServiceCompat +import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.module.IoDispatcher @@ -19,11 +22,17 @@ import com.zaneschepke.wireguardautotunnel.service.notification.NotificationServ import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage +import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList +import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable +import com.zaneschepke.wireguardautotunnel.util.extensions.onNotRunning import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -33,7 +42,7 @@ import javax.inject.Inject import javax.inject.Provider @AndroidEntryPoint -class AutoTunnelService : ForegroundService() { +class AutoTunnelService : LifecycleService() { private val foregroundId = 122 @Inject @@ -65,8 +74,14 @@ class AutoTunnelService : ForegroundService() { private val networkEventsFlow = MutableStateFlow(AutoTunnelState()) private var wakeLock: PowerManager.WakeLock? = null - private val tag = this.javaClass.name + private var wifiJob: Job? = null + private var mobileDataJob: Job? = null + private var ethernetJob: Job? = null + private var pingJob: Job? = null + private var networkEventJob: Job? = null + + @get:Synchronized @set:Synchronized private var running: Boolean = false override fun onCreate() { @@ -80,6 +95,26 @@ class AutoTunnelService : ForegroundService() { } } + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + // We don't provide binding, so return null + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("onStartCommand executed with startId: $startId") + if (intent != null) { + val action = intent.action + when (action) { + Action.START.name, + Action.START_FOREGROUND.name, + -> startService() + Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService() + } + } + return super.onStartCommand(intent, flags, startId) + } + private suspend fun launchNotification() { if (appDataRepository.settings.getSettings().isAutoTunnelPaused) { launchWatcherPausedNotification() @@ -88,27 +123,33 @@ class AutoTunnelService : ForegroundService() { } } - override fun startService(extras: Bundle?) { - super.startService(extras) + private fun startService() { if (running) return + running = true kotlin.runCatching { lifecycleScope.launch(mainImmediateDispatcher) { launchNotification() initWakeLock() } - startWatcherJob() + startSettingsJob() }.onFailure { Timber.e(it) } } - override fun stopService() { - super.stopService() + private fun stopService() { wakeLock?.let { if (it.isHeld) { it.release() } } + stopSelf() + } + + override fun onDestroy() { + cancelAndResetNetworkJobs() + cancelAndResetPingJob() + super.onDestroy() } private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) { @@ -134,6 +175,7 @@ class AutoTunnelService : ForegroundService() { private fun initWakeLock() { wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + val tag = this.javaClass.name newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { try { Timber.i("Initiating wakelock with 10 min timeout") @@ -145,43 +187,33 @@ class AutoTunnelService : ForegroundService() { } } - private fun startWatcherJob() = lifecycleScope.launch { - val setting = appDataRepository.settings.getSettings() - launch { - Timber.i("Starting wifi watcher") - watchForWifiConnectivityChanges() - } - if (setting.isTunnelOnMobileDataEnabled) { - launch { - Timber.i("Starting mobile data watcher") - watchForMobileDataConnectivityChanges() - } - } - if (setting.isTunnelOnEthernetEnabled) { - launch { - Timber.i("Starting ethernet data watcher") - watchForEthernetConnectivityChanges() - } - } - launch { - Timber.i("Starting settings watcher") - watchForSettingsChanges() - } - if (setting.isPingEnabled) { - launch { - Timber.i("Starting ping watcher") - watchForPingFailure() - } - } - launch { - Timber.i("Starting management watcher") - manageVpn() - } - running = true + private fun startSettingsJob() = lifecycleScope.launch { + watchForSettingsChanges() + } + + private fun startWifiJob() = lifecycleScope.launch { + watchForWifiConnectivityChanges() + } + + private fun startMobileDataJob() = lifecycleScope.launch { + watchForMobileDataConnectivityChanges() + } + + private fun startEthernetJob() = lifecycleScope.launch { + watchForEthernetConnectivityChanges() + } + + private fun startPingJob() = lifecycleScope.launch { + watchForPingFailure() + } + + private fun startNetworkEventJob() = lifecycleScope.launch { + handleNetworkEventChanges() } private suspend fun watchForMobileDataConnectivityChanges() { withContext(ioDispatcher) { + Timber.i("Starting mobile data watcher") mobileDataService.networkStatus.collect { status -> when (status) { is NetworkStatus.Available -> { @@ -217,92 +249,155 @@ class AutoTunnelService : ForegroundService() { private suspend fun watchForPingFailure() { withContext(ioDispatcher) { - try { + Timber.i("Starting ping watcher") + runCatching { do { - if (tunnelService.get().vpnState.value.status == TunnelState.UP) { - val tunnelConfig = tunnelService.get().vpnState.value.tunnelConfig - tunnelConfig?.let { - val config = TunnelConfig.configFromWgQuick(it.wgQuick) - val results = + val vpnState = tunnelService.get().vpnState.value + if (vpnState.status == TunnelState.UP) { + if (vpnState.tunnelConfig != null) { + val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick) + val results = if (vpnState.tunnelConfig.pingIp != null) { + Timber.d("Pinging custom ip : ${vpnState.tunnelConfig.pingIp}") + listOf(InetAddress.getByName(vpnState.tunnelConfig.pingIp).isReachable(Constants.PING_TIMEOUT.toInt())) + } else { + Timber.d("Pinging all peers") config.peers.map { peer -> - val host = - if (peer.endpoint.isPresent && - peer.endpoint.get().resolved.isPresent - ) { - peer.endpoint.get().resolved.get().host - } else { - Constants.DEFAULT_PING_IP - } - Timber.i("Checking reachability of: $host") - val reachable = - InetAddress.getByName(host) - .isReachable(Constants.PING_TIMEOUT.toInt()) - Timber.i("Result: reachable - $reachable") - reachable + peer.isReachable() } + } + Timber.i("Ping results reachable: $results") if (results.contains(false)) { Timber.i("Restarting VPN for ping failure") - tunnelService.get().stopTunnel(it) - delay(Constants.VPN_RESTART_DELAY) - tunnelService.get().startTunnel(it) - delay(Constants.PING_COOLDOWN) + val cooldown = vpnState.tunnelConfig.pingCooldown + tunnelService.get().bounceTunnel(vpnState.tunnelConfig) + delay(cooldown ?: Constants.PING_COOLDOWN) + continue } } } - delay(Constants.PING_INTERVAL) + delay(vpnState.tunnelConfig?.pingInterval ?: Constants.PING_INTERVAL) } while (true) - } catch (e: Exception) { - Timber.e(e) + }.onFailure { + Timber.e(it) + } + } + } + + private fun updateSettings(settings: Settings) { + networkEventsFlow.update { + it.copy( + settings = settings, + ) + } + } + + private fun onAutoTunnelPause(paused: Boolean) { + if (networkEventsFlow.value.settings.isAutoTunnelPaused + != paused + ) { + when (paused) { + true -> launchWatcherPausedNotification() + false -> launchWatcherNotification() } } } private suspend fun watchForSettingsChanges() { - appDataRepository.settings.getSettingsFlow().collect { settings -> - if (networkEventsFlow.value.settings.isAutoTunnelPaused - != settings.isAutoTunnelPaused - ) { - when (settings.isAutoTunnelPaused) { - true -> launchWatcherPausedNotification() - false -> launchWatcherNotification() + Timber.i("Starting settings watcher") + withContext(ioDispatcher) { + appDataRepository.settings.getSettingsFlow().combine( + appDataRepository.tunnels.getTunnelConfigsFlow(), + ) { settings, tunnels -> + val activeTunnel = tunnels.firstOrNull { it.isActive } + if (!settings.isPingEnabled) { + settings.copy(isPingEnabled = activeTunnel?.isPingEnabled ?: false) + } else { + settings } + }.collect { + Timber.d("Settings change: $it") + onAutoTunnelPause(it.isAutoTunnelPaused) + updateSettings(it) + manageJobsBySettings(it) } - networkEventsFlow.update { - it.copy( - settings = settings, - ) + } + } + + private fun manageJobsBySettings(settings: Settings) { + with(settings) { + if (isPingEnabled) { + pingJob.onNotRunning { pingJob = startPingJob() } + } else { + cancelAndResetPingJob() } + if (isTunnelOnWifiEnabled || isTunnelOnEthernetEnabled || isTunnelOnMobileDataEnabled) { + startNetworkJobs() + } else { + cancelAndResetNetworkJobs() + } + } + } + + private fun startNetworkJobs() { + wifiJob.onNotRunning { + Timber.i("Wifi job starting") + wifiJob = startWifiJob() + } + ethernetJob.onNotRunning { + ethernetJob = startEthernetJob() + Timber.i("Ethernet job starting") + } + mobileDataJob.onNotRunning { + mobileDataJob = startMobileDataJob() + Timber.i("Mobile data job starting") + } + networkEventJob.onNotRunning { + Timber.i("Network event job starting") + networkEventJob = startNetworkEventJob() + } + } + + private fun cancelAndResetPingJob() { + pingJob?.cancelWithMessage("Ping job canceled") + pingJob = null + } + + private fun cancelAndResetNetworkJobs() { + networkEventJob?.cancelWithMessage("Network event job canceled") + wifiJob?.cancelWithMessage("Wifi job canceled") + ethernetJob?.cancelWithMessage("Ethernet job canceled") + mobileDataJob?.cancelWithMessage("Mobile data job canceled") + networkEventJob = null + wifiJob = null + ethernetJob = null + mobileDataJob = null + } + + private fun updateEthernet(connected: Boolean) { + networkEventsFlow.update { + it.copy( + isEthernetConnected = connected, + ) } } private suspend fun watchForEthernetConnectivityChanges() { withContext(ioDispatcher) { + Timber.i("Starting ethernet data watcher") ethernetService.networkStatus.collect { status -> when (status) { is NetworkStatus.Available -> { Timber.i("Gained Ethernet connection") - networkEventsFlow.update { - it.copy( - isEthernetConnected = true, - ) - } + updateEthernet(true) } is NetworkStatus.CapabilitiesChanged -> { Timber.i("Ethernet capabilities changed") - networkEventsFlow.update { - it.copy( - isEthernetConnected = true, - ) - } + updateEthernet(true) } is NetworkStatus.Unavailable -> { - networkEventsFlow.update { - it.copy( - isEthernetConnected = false, - ) - } + updateEthernet(false) Timber.i("Lost Ethernet connection") } } @@ -312,6 +407,7 @@ class AutoTunnelService : ForegroundService() { private suspend fun watchForWifiConnectivityChanges() { withContext(ioDispatcher) { + Timber.i("Starting wifi watcher") wifiService.networkStatus.collect { status -> when (status) { is NetworkStatus.Available -> { @@ -371,8 +467,9 @@ class AutoTunnelService : ForegroundService() { return tunnelService.get().vpnState.value.status == TunnelState.DOWN } - private suspend fun manageVpn() { + private suspend fun handleNetworkEventChanges() { withContext(ioDispatcher) { + Timber.i("Starting network event watcher") networkEventsFlow.collectLatest { watcherState -> val autoTunnel = "Auto-tunnel watcher" if (!watcherState.settings.isAutoTunnelPaused) { @@ -412,8 +509,9 @@ class AutoTunnelService : ForegroundService() { } watcherState.isUntrustedWifiConditionMet() -> { - if (activeTunnel?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false || - activeTunnel == null + Timber.i("Untrusted wifi condition met") + if (activeTunnel?.tunnelNetworks?.isMatchingToWildcardList(watcherState.currentNetworkSSID) == false || + activeTunnel == null || isTunnelDown() ) { Timber.i( "$autoTunnel - tunnel on ssid not associated with current tunnel condition met", diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelState.kt index 518b07e..c030879 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/AutoTunnelState.kt @@ -1,6 +1,7 @@ package com.zaneschepke.wireguardautotunnel.service.foreground import com.zaneschepke.wireguardautotunnel.data.domain.Settings +import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList data class AutoTunnelState( val isWifiConnected: Boolean = false, @@ -38,7 +39,7 @@ data class AutoTunnelState( return ( !isEthernetConnected && isWifiConnected && - !settings.trustedNetworkSSIDs.contains(currentNetworkSSID) && + !settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID) && settings.isTunnelOnWifiEnabled ) } @@ -48,7 +49,7 @@ data class AutoTunnelState( !isEthernetConnected && ( isWifiConnected && - settings.trustedNetworkSSIDs.contains(currentNetworkSSID) + settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID) ) ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt deleted file mode 100644 index 908b802..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.service.foreground - -import android.content.Intent -import android.os.Bundle -import android.os.IBinder -import androidx.lifecycle.LifecycleService -import com.zaneschepke.wireguardautotunnel.util.Constants -import timber.log.Timber - -open class ForegroundService : LifecycleService() { - private var isServiceStarted = false - - override fun onBind(intent: Intent): IBinder? { - super.onBind(intent) - // We don't provide binding, so return null - return null - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - Timber.d("onStartCommand executed with startId: $startId") - if (intent != null) { - val action = intent.action - when (action) { - Action.START.name, - Action.START_FOREGROUND.name, - -> startService(intent.extras) - - Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService() - Constants.ALWAYS_ON_VPN_ACTION -> { - Timber.i("Always-on VPN starting service") - startService(intent.extras) - } - - else -> Timber.d("This should never happen. No action in the received intent") - } - } else { - Timber.d( - "with a null intent. It has been probably restarted by the system.", - ) - } - return START_STICKY - } - - protected open fun startService(extras: Bundle?) { - if (isServiceStarted) return - Timber.d("Starting ${this.javaClass.simpleName}") - isServiceStarted = true - } - - protected open fun stopService() { - Timber.d("Stopping ${this.javaClass.simpleName}") - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - isServiceStarted = false - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt index 5f20da3..71939ea 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt @@ -3,10 +3,12 @@ package com.zaneschepke.wireguardautotunnel.service.foreground import android.app.Service import android.content.Context import android.content.Intent +import android.net.VpnService import timber.log.Timber -class ServiceManager { +object ServiceManager { private fun actionOnService(action: Action, context: Context, cls: Class, extras: Map? = null) { + if (VpnService.prepare(context) != null) return val intent = Intent(context, cls).also { it.action = action.name diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/TunnelBackgroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/TunnelBackgroundService.kt index fb94715..b59082f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/TunnelBackgroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/TunnelBackgroundService.kt @@ -1,14 +1,16 @@ package com.zaneschepke.wireguardautotunnel.service.foreground import android.app.Notification -import android.os.Bundle +import android.content.Intent +import android.os.IBinder +import androidx.lifecycle.LifecycleService import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint -class TunnelBackgroundService : ForegroundService() { +class TunnelBackgroundService : LifecycleService() { @Inject lateinit var notificationService: NotificationService @@ -20,14 +22,32 @@ class TunnelBackgroundService : ForegroundService() { startForeground(foregroundId, createNotification()) } - override fun startService(extras: Bundle?) { - super.startService(extras) + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + // We don't provide binding, so return null + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + val action = intent.action + when (action) { + Action.START.name, + Action.START_FOREGROUND.name, + -> startService() + Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService() + } + } + return super.onStartCommand(intent, flags, startId) + } + + private fun startService() { startForeground(foregroundId, createNotification()) } - override fun stopService() { - super.stopService() + private fun stopService() { stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() } private fun createNotification(): Notification { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/AutoTunnelControlTile.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/AutoTunnelControlTile.kt index 8036765..3bcd9db 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/AutoTunnelControlTile.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/AutoTunnelControlTile.kt @@ -12,7 +12,6 @@ import androidx.lifecycle.lifecycleScope import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.module.ApplicationScope -import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -24,9 +23,6 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner { @Inject lateinit var appDataRepository: AppDataRepository - @Inject - lateinit var serviceManager: ServiceManager - @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/TunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/TunnelService.kt index d40af70..d1279dc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/TunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/TunnelService.kt @@ -9,6 +9,8 @@ interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel { suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result + suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result + val vpnState: StateFlow suspend fun runningTunnelNames(): Set diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt index 27f20cc..c510ad1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt @@ -33,11 +33,12 @@ class WireGuardTunnel constructor( private val amneziaBackend: Provider, @Userspace private val userspaceBackend: Provider, - @Kernel private val kernelBackend: Provider, + @Kernel private val kernelBackend: Provider, private val appDataRepository: AppDataRepository, @ApplicationScope private val applicationScope: CoroutineScope, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : TunnelService { + private val _vpnState = MutableStateFlow(VpnState()) override val vpnState: StateFlow = _vpnState.asStateFlow() @@ -84,30 +85,42 @@ constructor( override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result { return withContext(ioDispatcher) { if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) } - appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true)) - appDataRepository.appState.setLastActiveTunnelId(tunnelConfig.id) emitTunnelConfig(tunnelConfig) setState(tunnelConfig, TunnelState.UP).onSuccess { emitTunnelState(it) - WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate() + appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true)) }.onFailure { - appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false)) - WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate() + Timber.e(it) } } } override suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result { return withContext(ioDispatcher) { - appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false)) setState(tunnelConfig, TunnelState.DOWN).onSuccess { + emitTunnelState(it) + appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false)) + resetBackendStatistics() + }.onFailure { + Timber.e(it) + } + } + } + + // use this when we just want to bounce tunnel and not change tunnelConfig active state + override suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result { + toggleTunnel(tunnelConfig) + delay(Constants.VPN_RESTART_DELAY) + return toggleTunnel(tunnelConfig) + } + + private suspend fun toggleTunnel(tunnelConfig: TunnelConfig): Result { + return withContext(ioDispatcher) { + setState(tunnelConfig, TunnelState.TOGGLE).onSuccess { emitTunnelState(it) resetBackendStatistics() - WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate() }.onFailure { Timber.e(it) - appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true)) - WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate() } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppUiState.kt index 281f346..04cdace 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppUiState.kt @@ -1,8 +1,13 @@ package com.zaneschepke.wireguardautotunnel.ui +import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState +import com.zaneschepke.wireguardautotunnel.data.domain.Settings +import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig +import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState + data class AppUiState( - val snackbarMessage: String = "", - val snackbarMessageConsumed: Boolean = true, - val notificationPermissionAccepted: Boolean = false, - val requestPermissions: Boolean = false, + val settings: Settings = Settings(), + val tunnels: List = emptyList(), + val vpnState: VpnState = VpnState(), + val generalState: GeneralState = GeneralState(), ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt index 620ad54..a4ffafe 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt @@ -2,11 +2,16 @@ package com.zaneschepke.wireguardautotunnel.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository +import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import xyz.teamgravity.pin_lock_compose.PinManager import javax.inject.Inject @@ -16,30 +21,38 @@ class AppViewModel @Inject constructor( private val appDataRepository: AppDataRepository, + private val tunnelService: TunnelService, + val navHostController: NavHostController, ) : ViewModel() { - private val _appUiState = - MutableStateFlow( - AppUiState(), + private val _appUiState = MutableStateFlow(AppUiState()) + + val uiState = + combine( + appDataRepository.settings.getSettingsFlow(), + appDataRepository.tunnels.getTunnelConfigsFlow(), + tunnelService.vpnState, + appDataRepository.appState.generalStateFlow, + ) { settings, tunnels, tunnelState, generalState -> + AppUiState( + settings, + tunnels, + tunnelState, + generalState, + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), + _appUiState.value, + ) + + fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch { + _appUiState.emit( + _appUiState.value.copy( + tunnels = tunnels, + ), ) - val appUiState = _appUiState.asStateFlow() - - fun showSnackbarMessage(message: String) { - _appUiState.update { - it.copy( - snackbarMessage = message, - snackbarMessageConsumed = false, - ) - } - } - - fun snackbarMessageConsumed() { - _appUiState.update { - it.copy( - snackbarMessage = "", - snackbarMessageConsumed = true, - ) - } } fun onPinLockDisabled() = viewModelScope.launch { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index 071e1ab..6a2487a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -14,10 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarData -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.LaunchedEffect @@ -31,17 +28,18 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository +import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService +import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen @@ -52,10 +50,8 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -71,158 +67,148 @@ class MainActivity : AppCompatActivity() { val isPinLockEnabled = intent.extras?.getBoolean(SplashActivity.IS_PIN_LOCK_ENABLED_KEY) - enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb())) + enableEdgeToEdge( + navigationBarStyle = SystemBarStyle.auto( + lightScrim = Color.Transparent.toArgb(), + darkScrim = Color.Transparent.toArgb(), + ), + ) setContent { val appViewModel = hiltViewModel() - val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle() - val navController = rememberNavController() + val appUiState by appViewModel.uiState.collectAsStateWithLifecycle(lifecycle = this.lifecycle) + val navController = appViewModel.navHostController val navBackStackEntry by navController.currentBackStackEntryAsState() - val snackbarHostState = remember { SnackbarHostState() } - - fun showSnackBarMessage(message: StringValue) { - lifecycleScope.launch(Dispatchers.Main) { - val result = - snackbarHostState.showSnackbar( - message = message.asString(this@MainActivity), - duration = SnackbarDuration.Short, - ) - when (result) { - SnackbarResult.ActionPerformed, - SnackbarResult.Dismissed, - -> { - snackbarHostState.currentSnackbarData?.dismiss() - } - } + LaunchedEffect(appUiState.vpnState.status) { + val context = this@MainActivity + when (appUiState.vpnState.status) { + TunnelState.DOWN -> ServiceManager.stopTunnelBackgroundService(context) + else -> Unit } + context.requestTunnelTileServiceStateUpdate() } - WireguardAutoTunnelTheme { - LaunchedEffect(appUiState.snackbarMessageConsumed) { - if (!appUiState.snackbarMessageConsumed) { - showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage)) - appViewModel.snackbarMessageConsumed() - } - } - - val focusRequester = remember { FocusRequester() } - - Scaffold( - snackbarHost = { - SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> - CustomSnackBar( - snackbarData.visuals.message, - isRtl = false, - containerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation( - 2.dp, - ), - ) - } - }, - containerColor = MaterialTheme.colorScheme.background, - modifier = - Modifier - .focusable() - .focusProperties { - when (navBackStackEntry?.destination?.route) { - Screen.Lock.route -> Unit - else -> up = focusRequester + SnackbarControllerProvider { host -> + WireguardAutoTunnelTheme { + val focusRequester = remember { FocusRequester() } + Scaffold( + snackbarHost = { + SnackbarHost(host) { snackbarData: SnackbarData -> + CustomSnackBar( + snackbarData.visuals.message, + isRtl = false, + containerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation( + 2.dp, + ), + ) } }, - bottomBar = { - BottomNavBar( - navController, - listOf( - Screen.Main.navItem, - Screen.Settings.navItem, - Screen.Support.navItem, - ), - ) - }, - ) { padding -> - Surface(modifier = Modifier.fillMaxSize().padding(padding)) { - NavHost( - navController, - enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) }, - exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) }, - startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route), - ) { - composable( - Screen.Main.route, - ) { - MainScreen( - focusRequester = focusRequester, - appViewModel = appViewModel, - navController = navController, - ) - } - composable( - Screen.Settings.route, - ) { - SettingsScreen( - appViewModel = appViewModel, - navController = navController, - focusRequester = focusRequester, - ) - } - composable( - Screen.Support.route, - ) { - SupportScreen( - focusRequester = focusRequester, - navController = navController, - ) - } - composable(Screen.Support.Logs.route) { - LogsScreen() - } - composable( - "${Screen.Config.route}/{id}?configType={configType}", - arguments = + containerColor = MaterialTheme.colorScheme.background, + modifier = + Modifier + .focusable() + .focusProperties { + when (navBackStackEntry?.destination?.route) { + Screen.Lock.route -> Unit + else -> up = focusRequester + } + }, + bottomBar = { + BottomNavBar( + navController, listOf( - navArgument("id") { - type = NavType.StringType - defaultValue = "0" - }, - navArgument("configType") { - type = NavType.StringType - defaultValue = ConfigType.WIREGUARD.name - }, + Screen.Main.navItem, + Screen.Settings.navItem, + Screen.Support.navItem, ), + ) + }, + ) { padding -> + Surface(modifier = Modifier.fillMaxSize().padding(padding)) { + NavHost( + navController, + enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) }, + exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) }, + startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route), ) { - val id = it.arguments?.getString("id") - val configType = - ConfigType.valueOf( - it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name, - ) - if (!id.isNullOrBlank()) { - ConfigScreen( - navController = navController, - tunnelId = id, - appViewModel = appViewModel, + composable( + Screen.Main.route, + ) { + MainScreen( focusRequester = focusRequester, - configType = configType, + uiState = appUiState, + navController = navController, ) } - } - composable("${Screen.Option.route}/{id}") { - val id = it.arguments?.getString("id") - if (!id.isNullOrBlank()) { - OptionsScreen( - navController = navController, - tunnelId = id, + composable( + Screen.Settings.route, + ) { + SettingsScreen( appViewModel = appViewModel, + uiState = appUiState, + navController = navController, focusRequester = focusRequester, ) } - } - composable(Screen.Lock.route) { - PinLockScreen( - navController = navController, - appViewModel = appViewModel, - ) + composable( + Screen.Support.route, + ) { + SupportScreen( + focusRequester = focusRequester, + navController = navController, + appUiState = appUiState, + ) + } + composable(Screen.Support.Logs.route) { + LogsScreen() + } + composable( + "${Screen.Config.route}/{id}?configType={configType}", + arguments = + listOf( + navArgument("id") { + type = NavType.StringType + defaultValue = "0" + }, + navArgument("configType") { + type = NavType.StringType + defaultValue = ConfigType.WIREGUARD.name + }, + ), + ) { + val id = it.arguments?.getString("id") + val configType = + ConfigType.valueOf( + it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name, + ) + if (!id.isNullOrBlank()) { + ConfigScreen( + navController = navController, + tunnelId = id, + focusRequester = focusRequester, + configType = configType, + ) + } + } + composable("${Screen.Option.route}/{id}") { + val id = it.arguments?.getString("id") + if (!id.isNullOrBlank()) { + OptionsScreen( + navController = navController, + tunnelId = id.toInt(), + focusRequester = focusRequester, + appUiState = appUiState, + ) + } + } + composable(Screen.Lock.route) { + PinLockScreen( + navController = navController, + appViewModel = appViewModel, + ) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SplashActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SplashActivity.kt index 8d2d946..f21f106 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SplashActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/SplashActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -16,8 +17,8 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate -import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.async import kotlinx.coroutines.launch import xyz.teamgravity.pin_lock_compose.PinManager import javax.inject.Inject @@ -35,8 +36,7 @@ class SplashActivity : ComponentActivity() { @Inject lateinit var tunnelService: Provider - @Inject - lateinit var serviceManager: ServiceManager + private val appViewModel: AppViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -47,16 +47,28 @@ class SplashActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { - val pinLockEnabled = appStateRepository.isPinLockEnabled() - if (pinLockEnabled) { - PinManager.initialize(WireGuardAutoTunnel.instance) - } - val settings = appDataRepository.settings.getSettings() - if (settings.isAutoTunnelEnabled) serviceManager.startWatcherService(application.applicationContext) - if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob() - val tunnels = appDataRepository.tunnels.getActive() - if (tunnels.isNotEmpty() && tunnelService.get().getState() == TunnelState.DOWN) tunnelService.get().startTunnel(tunnels.first()) - requestTunnelTileServiceStateUpdate() + val pinLockEnabled = async { + appStateRepository.isPinLockEnabled().also { + if (it) PinManager.initialize(WireGuardAutoTunnel.instance) + } + }.await() + async { + val settings = appDataRepository.settings.getSettings() + if (settings.isAutoTunnelEnabled) ServiceManager.startWatcherService(application.applicationContext) + if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob() + val activeTunnels = appDataRepository.tunnels.getActive() + if (activeTunnels.isNotEmpty() && + tunnelService.get().getState() == TunnelState.DOWN + ) { + tunnelService.get().startTunnel(activeTunnels.first()) + } + }.await() + + async { + val tunnels = appDataRepository.tunnels.getAll() + appViewModel.setTunnels(tunnels) + }.await() + requestAutoTunnelTileServiceUpdate() val intent = diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt index ae1f193..cd1ed79 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @Composable -fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean) { +fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean = true) { TextButton( onClick = onClick, enabled = enabled, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt index 24eae6c..9330a7a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt @@ -1,5 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField @@ -14,23 +15,29 @@ fun ConfigurationTextBox( value: String, hint: String, onValueChange: (String) -> Unit, - keyboardActions: KeyboardActions, + keyboardActions: KeyboardActions = KeyboardActions(), label: String, modifier: Modifier, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + trailing: @Composable () -> Unit = {}, + interactionSource: MutableInteractionSource? = null, ) { OutlinedTextField( + isError = isError, modifier = modifier, value = value, singleLine = true, + interactionSource = interactionSource, onValueChange = { onValueChange(it) }, label = { Text(label) }, maxLines = 1, placeholder = { Text(hint) }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - ), + keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + trailingIcon = trailing, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt index f990030..bae787d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt @@ -13,7 +13,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @Composable -fun ConfigurationToggle(label: String, enabled: Boolean, checked: Boolean, padding: Dp, onCheckChanged: () -> Unit, modifier: Modifier = Modifier) { +fun ConfigurationToggle( + label: String, + enabled: Boolean = true, + checked: Boolean, + padding: Dp, + onCheckChanged: () -> Unit, + modifier: Modifier = Modifier, +) { Row( modifier = Modifier 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 new file mode 100644 index 0000000..0986526 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/SubmitConfigurationTextBox.kt @@ -0,0 +1,76 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.config + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import com.zaneschepke.wireguardautotunnel.R + +@Composable +fun SubmitConfigurationTextBox( + value: String?, + label: String, + hint: String, + focusRequester: FocusRequester, + isErrorValue: (value: String?) -> Boolean, + onSubmit: (value: String) -> Unit, + keyboardOptions: KeyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), +) { + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val keyboardController = LocalSoftwareKeyboardController.current + + var stateValue by remember { mutableStateOf(value) } + + ConfigurationTextBox( + isError = isErrorValue(stateValue), + interactionSource = interactionSource, + value = stateValue ?: "", + onValueChange = { + stateValue = it + }, + keyboardOptions = keyboardOptions, + label = label, + hint = hint, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailing = { + if (!stateValue.isNullOrBlank() && !isErrorValue(stateValue) && isFocused) { + IconButton(onClick = { + onSubmit(stateValue!!) + keyboardController?.hide() + focusManager.clearFocus() + }) { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.save_changes), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/NavigationService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/NavigationService.kt new file mode 100644 index 0000000..685e037 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/NavigationService.kt @@ -0,0 +1,15 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.navigation + +import android.content.Context +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.DialogNavigator + +class NavigationService constructor( + context: Context, +) { + val navController = NavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + navigatorProvider.addNavigator(DialogNavigator()) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/SnackbarController.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/SnackbarController.kt new file mode 100644 index 0000000..e7b445e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/snackbar/SnackbarController.kt @@ -0,0 +1,108 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.snackbar + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext +import com.zaneschepke.wireguardautotunnel.util.StringValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlin.coroutines.EmptyCoroutineContext + +private val LocalSnackbarController = staticCompositionLocalOf { + SnackbarController( + host = SnackbarHostState(), + scope = CoroutineScope(EmptyCoroutineContext), + ) +} +private val channel = Channel(capacity = 1) + +@Composable +fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) { + val snackHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val snackController = remember(scope) { SnackbarController(snackHostState, scope) } + val context = LocalContext.current + + DisposableEffect(snackController, scope) { + val job = scope.launch { + for (payload in channel) { + snackController.showMessage( + message = payload.message.asString(context), + duration = payload.duration, + action = payload.action, + ) + } + } + + onDispose { + job.cancel() + } + } + + CompositionLocalProvider(LocalSnackbarController provides snackController) { + content( + snackHostState, + ) + } +} + +@Immutable +class SnackbarController( + private val host: SnackbarHostState, + private val scope: CoroutineScope, +) { + companion object { + val current + @Composable + @ReadOnlyComposable + get() = LocalSnackbarController.current + + fun showMessage(message: StringValue, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) { + channel.trySend( + SnackbarChannelMessage( + message = message, + duration = duration, + action = action, + ), + ) + } + } + + fun showMessage(message: String, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) { + scope.launch { + /** + * note: uncomment this line if you want snackbar to be displayed immediately, + * rather than being enqueued and waiting [duration] * current_queue_size + */ + host.currentSnackbarData?.dismiss() + val result = + host.showSnackbar( + message = message, + actionLabel = action?.title, + duration = duration, + ) + + if (result == SnackbarResult.ActionPerformed) { + action?.onActionPress?.invoke() + } + } + } +} + +data class SnackbarChannelMessage( + val message: StringValue, + val action: SnackbarAction?, + val duration: SnackbarDuration = SnackbarDuration.Short, +) + +data class SnackbarAction(val title: String, val onActionPress: () -> Unit) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt index b1115ad..326cc02 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -70,12 +70,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.drawablepainter.DrawablePainter import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType import com.zaneschepke.wireguardautotunnel.util.Constants @@ -92,11 +92,11 @@ fun ConfigScreen( viewModel: ConfigViewModel = hiltViewModel(), focusRequester: FocusRequester, navController: NavController, - appViewModel: AppViewModel, tunnelId: String, configType: ConfigType, ) { val context = LocalContext.current + val snackbar = SnackbarController.current val clipboardManager: ClipboardManager = LocalClipboardManager.current val keyboardController = LocalSoftwareKeyboardController.current var showApplicationsDialog by remember { mutableStateOf(false) } @@ -160,13 +160,13 @@ fun ConfigScreen( }, onError = { showAuthPrompt = false - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.error_authentication_failed), ) }, onFailure = { showAuthPrompt = false - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.error_authorization_failed), ) }, @@ -341,12 +341,12 @@ fun ConfigScreen( }, onClick = { viewModel.onSaveAllChanges(configType).onSuccess { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.config_changes_saved), ) navController.navigate(Screen.Main.route) }.onFailure { - appViewModel.showSnackbarMessage(it.getMessage(context)) + snackbar.showMessage(it.getMessage(context)) } }, containerColor = fobColor, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt index 2b4872e..f59bc3e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt @@ -28,7 +28,7 @@ data class InterfaceProxy( dnsServers = listOf( i.dnsServers.joinToString(", ").replace("/", "").trim(), i.dnsSearchDomains.joinToString(", ").trim(), - ).filter { it.length > 0 } .joinToString(", "), + ).filter { it.length > 0 }.joinToString(", "), listenPort = if (i.listenPort.isPresent) { i.listenPort.get().toString().trim() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index 9b7dbd2..5a5cec2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions @@ -67,12 +66,12 @@ import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState -import com.zaneschepke.wireguardautotunnel.ui.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult -import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet @@ -93,14 +92,10 @@ import timber.log.Timber @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalFoundationApi::class) @Composable -fun MainScreen( - viewModel: MainViewModel = hiltViewModel(), - appViewModel: AppViewModel, - focusRequester: FocusRequester, - navController: NavController, -) { +fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState, focusRequester: FocusRequester, navController: NavController) { val haptic = LocalHapticFeedback.current val context = LocalContext.current + val snackbar = SnackbarController.current val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } @@ -109,7 +104,6 @@ fun MainScreen( val isVisible = rememberSaveable { mutableStateOf(true) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) } var selectedTunnel by remember { mutableStateOf(null) } - val uiState by viewModel.uiState.collectAsStateWithLifecycle() val nestedScrollConnection = remember { @@ -154,13 +148,13 @@ fun MainScreen( } val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.error_no_file_explorer), ) }, onData = { data -> scope.launch { viewModel.onTunnelFileSelected(data, configType, context).onFailure { - appViewModel.showSnackbarMessage(it.getMessage(context)) + snackbar.showMessage(it.getMessage(context)) } } }) @@ -172,7 +166,7 @@ fun MainScreen( if (it.contents != null) { scope.launch { viewModel.onTunnelQrResult(it.contents, configType).onFailure { error -> - appViewModel.showSnackbarMessage(error.getMessage(context)) + snackbar.showMessage(error.getMessage(context)) } } } @@ -209,10 +203,6 @@ fun MainScreen( } } - if (uiState.loading) { - return LoadingScreen() - } - fun launchQrScanner() { val scanOptions = ScanOptions() scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) @@ -398,7 +388,7 @@ fun MainScreen( (uiState.vpnState.status == TunnelState.UP) && (tunnel.name == uiState.vpnState.tunnelConfig?.name) ) { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.turn_off_tunnel), ) return@RowListItem @@ -433,7 +423,7 @@ fun MainScreen( uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused ) { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.turn_off_tunnel), ) } else { @@ -482,7 +472,7 @@ fun MainScreen( IconButton( onClick = { if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.turn_off_auto), ) } else { @@ -508,7 +498,7 @@ fun MainScreen( ) { expanded.value = !expanded.value } else { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.turn_on_tunnel), ) } @@ -529,7 +519,7 @@ fun MainScreen( uiState.vpnState.status == TunnelState.UP && tunnel.name == uiState.vpnState.tunnelConfig?.name ) { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.turn_off_tunnel), ) } else { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt deleted file mode 100644 index 0de6edc..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainUiState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main - -import com.zaneschepke.wireguardautotunnel.data.domain.Settings -import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState -import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs - -data class MainUiState( - val settings: Settings = Settings(), - val tunnels: TunnelConfigs = emptyList(), - val vpnState: VpnState = VpnState(), - val loading: Boolean = true, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index f18712f..e6bd8fb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -19,9 +19,6 @@ import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -34,26 +31,12 @@ class MainViewModel @Inject constructor( private val appDataRepository: AppDataRepository, - private val serviceManager: ServiceManager, val tunnelService: TunnelService, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { - val uiState = - combine( - appDataRepository.settings.getSettingsFlow(), - appDataRepository.tunnels.getTunnelConfigsFlow(), - tunnelService.vpnState, - ) { settings, tunnels, vpnState -> - MainUiState(settings, tunnels, vpnState, false) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - MainUiState(), - ) private fun stopWatcherService(context: Context) { - serviceManager.stopWatcherService(context) + ServiceManager.stopWatcherService(context) } fun onDelete(tunnel: TunnelConfig, context: Context) { @@ -299,14 +282,16 @@ constructor( } fun pauseAutoTunneling() = viewModelScope.launch { + val settings = appDataRepository.settings.getSettings() appDataRepository.settings.save( - uiState.value.settings.copy(isAutoTunnelPaused = true), + settings.copy(isAutoTunnelPaused = true), ) } fun resumeAutoTunneling() = viewModelScope.launch { + val settings = appDataRepository.settings.getSettings() appDataRepository.settings.save( - uiState.value.settings.copy(isAutoTunnelPaused = false), + settings.copy(isAutoTunnelPaused = false), ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt index 0bb1a39..fb928a5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,23 +43,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle +import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv +import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalLayoutApi::class) @@ -68,16 +67,15 @@ import kotlinx.coroutines.launch fun OptionsScreen( optionsViewModel: OptionsViewModel = hiltViewModel(), navController: NavController, - appViewModel: AppViewModel, focusRequester: FocusRequester, - tunnelId: String, + appUiState: AppUiState, + tunnelId: Int, ) { val scrollState = rememberScrollState() - val uiState by optionsViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val config = appUiState.tunnels.first { it.id == tunnelId } val interactionSource = remember { MutableInteractionSource() } - val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val screenPadding = 5.dp val fillMaxWidth = .85f @@ -85,7 +83,6 @@ fun OptionsScreen( var currentText by remember { mutableStateOf("") } LaunchedEffect(Unit) { - optionsViewModel.init(tunnelId) if (context.isRunningOnTv()) { delay(Constants.FOCUS_REQUEST_DELAY) kotlin.runCatching { @@ -99,13 +96,8 @@ fun OptionsScreen( fun saveTrustedSSID() { if (currentText.isNotEmpty()) { - scope.launch { - optionsViewModel.onSaveRunSSID(currentText).onSuccess { - currentText = "" - }.onFailure { - appViewModel.showSnackbarMessage(it.getMessage(context)) - } - } + optionsViewModel.onSaveRunSSID(currentText, config) + currentText = "" } } @@ -114,7 +106,7 @@ fun OptionsScreen( ScrollDismissMultiFab(R.drawable.edit, focusRequester, isVisible = true, onFabItemClicked = { val configType = ConfigType.valueOf(it.value) navController.navigate( - "${Screen.Config.route}/$tunnelId?configType=${configType.name}", + "${Screen.Config.route}/${config.id}?configType=${configType.name}", ) }) }, @@ -165,12 +157,12 @@ fun OptionsScreen( ConfigurationToggle( stringResource(R.string.set_primary_tunnel), enabled = true, - checked = uiState.isDefaultTunnel, + checked = config.isPrimaryTunnel, modifier = Modifier .focusRequester(focusRequester), padding = screenPadding, - onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel() }, + onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel(config) }, ) } } @@ -206,9 +198,9 @@ fun OptionsScreen( ConfigurationToggle( stringResource(R.string.mobile_data_tunnel), enabled = true, - checked = uiState.tunnel?.isMobileDataTunnel == true, + checked = config.isMobileDataTunnel, padding = screenPadding, - onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel() }, + onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel(config) }, ) Column { FlowRow( @@ -218,24 +210,24 @@ fun OptionsScreen( .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(5.dp), ) { - uiState.tunnel?.tunnelNetworks?.forEach { ssid -> + config.tunnelNetworks.forEach { ssid -> ClickableIconButton( onClick = { if (context.isRunningOnTv()) { focusRequester.requestFocus() - optionsViewModel.onDeleteRunSSID(ssid) + optionsViewModel.onDeleteRunSSID(ssid, config) } }, onIconClick = { if (context.isRunningOnTv()) focusRequester.requestFocus() - optionsViewModel.onDeleteRunSSID(ssid) + optionsViewModel.onDeleteRunSSID(ssid, config) }, text = ssid, icon = Icons.Filled.Close, enabled = true, ) } - if (uiState.tunnel == null || uiState.tunnel?.tunnelNetworks?.isEmpty() == true) { + if (config.tunnelNetworks.isEmpty()) { Text( stringResource(R.string.no_wifi_names_configured), fontStyle = FontStyle.Italic, @@ -267,26 +259,67 @@ fun OptionsScreen( IconButton(onClick = { saveTrustedSSID() }) { Icon( imageVector = Icons.Outlined.Add, - contentDescription = - if (currentText == "") { - stringResource( - id = - R.string - .trusted_ssid_empty_description, - ) - } else { - stringResource( - id = - R.string - .trusted_ssid_value_description, - ) - }, + contentDescription = stringResource(R.string.save_changes), tint = MaterialTheme.colorScheme.primary, ) } } }, ) + ConfigurationToggle( + stringResource(R.string.restart_on_ping), + enabled = !appUiState.settings.isPingEnabled, + checked = config.isPingEnabled || appUiState.settings.isPingEnabled, + padding = screenPadding, + onCheckChanged = { optionsViewModel.onToggleRestartOnPing(config) }, + ) + if (config.isPingEnabled || appUiState.settings.isPingEnabled) { + SubmitConfigurationTextBox( + config.pingIp, + stringResource(R.string.set_custom_ping_ip), + stringResource(R.string.default_ping_ip), + focusRequester, + isErrorValue = { !(it?.isValidIpv4orIpv6Address() ?: true) }, + onSubmit = { + optionsViewModel.saveTunnelChanges( + config.copy(pingIp = it), + ) + }, + ) + fun isSecondsError(seconds: String?): Boolean { + return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } ?: false + } + SubmitConfigurationTextBox( + config.pingInterval?.let { (it / 1000).toString() }, + stringResource(R.string.set_custom_ping_internal), + "(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})", + focusRequester, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + isErrorValue = ::isSecondsError, + onSubmit = { + optionsViewModel.saveTunnelChanges( + config.copy(pingInterval = it.toLong() * 1000), + ) + }, + ) + SubmitConfigurationTextBox( + config.pingCooldown?.let { (it / 1000).toString() }, + stringResource(R.string.set_custom_ping_cooldown), + "(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})", + focusRequester, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + isErrorValue = ::isSecondsError, + onSubmit = { + optionsViewModel.saveTunnelChanges( + config.copy(pingCooldown = it.toLong() * 1000), + ) + }, + ) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsUiState.kt deleted file mode 100644 index 60f57ff..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsUiState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.options - -import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig - -data class OptionsUiState( - val id: String? = null, - val tunnel: TunnelConfig? = null, - val isDefaultTunnel: Boolean = false, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt index 0844973..eecd3df 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt @@ -1,20 +1,14 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.options -import androidx.compose.ui.util.fastFirstOrNull import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.util.StringValue import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel @@ -23,86 +17,63 @@ class OptionsViewModel constructor( private val appDataRepository: AppDataRepository, ) : ViewModel() { - private val _optionState = MutableStateFlow(OptionsUiState()) - val uiState = - combine( - appDataRepository.tunnels.getTunnelConfigsFlow(), - _optionState, - ) { tunnels, optionState -> - if (optionState.id != null) { - val tunnelConfig = tunnels.fastFirstOrNull { it.id.toString() == optionState.id } - val isPrimaryTunnel = tunnelConfig?.isPrimaryTunnel == true - OptionsUiState(optionState.id, tunnelConfig, isPrimaryTunnel) - } else { - OptionsUiState() - } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - OptionsUiState(), + fun onDeleteRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save( + tunnelConfig = + tunnelConfig.copy( + tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(), + ), ) - - fun init(tunnelId: String) { - _optionState.update { - it.copy( - id = tunnelId, - ) - } } - fun onDeleteRunSSID(ssid: String) = viewModelScope.launch { - uiState.value.tunnel?.let { - appDataRepository.tunnels.save( - tunnelConfig = - it.copy( - tunnelNetworks = (uiState.value.tunnel!!.tunnelNetworks - ssid).toMutableList(), + fun saveTunnelChanges(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save(tunnelConfig) + } + + fun onSaveRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch { + val trimmed = ssid.trim() + val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed) + + if (!tunnelConfig.tunnelNetworks.contains(trimmed) && + tunnelsWithName.isEmpty() + ) { + saveTunnelChanges( + tunnelConfig.copy( + tunnelNetworks = (tunnelConfig.tunnelNetworks + ssid).toMutableList(), + ), + ) + } else { + SnackbarController.showMessage( + StringValue.StringResource( + R.string.error_ssid_exists, ), ) } } - private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch { - tunnelConfig?.let { - appDataRepository.tunnels.save(it) - } - } - - suspend fun onSaveRunSSID(ssid: String): Result { - val trimmed = ssid.trim() - val tunnelsWithName = - withContext(viewModelScope.coroutineContext) { - appDataRepository.tunnels.findByTunnelNetworksName(trimmed) - } - return if (uiState.value.tunnel?.tunnelNetworks?.contains(trimmed) != true && - tunnelsWithName.isEmpty() - ) { - uiState.value.tunnel?.tunnelNetworks?.add(trimmed) - saveTunnel(uiState.value.tunnel) - Result.success(Unit) + fun onToggleIsMobileDataTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { + if (tunnelConfig.isMobileDataTunnel) { + appDataRepository.tunnels.updateMobileDataTunnel(null) } else { - Result.failure(WgTunnelExceptions.SsidConflict()) + appDataRepository.tunnels.updateMobileDataTunnel(tunnelConfig) } } - fun onToggleIsMobileDataTunnel() = viewModelScope.launch { - uiState.value.tunnel?.let { - if (it.isMobileDataTunnel) { - appDataRepository.tunnels.updateMobileDataTunnel(null) - } else { - appDataRepository.tunnels.updateMobileDataTunnel(it) - } - } + fun onTogglePrimaryTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.updatePrimaryTunnel( + when (tunnelConfig.isPrimaryTunnel) { + true -> null + false -> tunnelConfig + }, + ) } - fun onTogglePrimaryTunnel() = viewModelScope.launch { - if (uiState.value.tunnel != null) { - appDataRepository.tunnels.updatePrimaryTunnel( - when (uiState.value.isDefaultTunnel) { - true -> null - false -> uiState.value.tunnel - }, - ) - } + fun onToggleRestartOnPing(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save( + tunnelConfig.copy( + isPingEnabled = !tunnelConfig.isPingEnabled, + ), + ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pinlock/PinLockScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pinlock/PinLockScreen.kt index 56a9ce9..7750665 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pinlock/PinLockScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/pinlock/PinLockScreen.kt @@ -9,6 +9,7 @@ import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.Screen +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import xyz.teamgravity.pin_lock_compose.PinLock @@ -16,9 +17,11 @@ import xyz.teamgravity.pin_lock_compose.PinLock @Composable fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) { val context = LocalContext.current + val snackbar = SnackbarController.current PinLock( title = { pinExists -> Text( + color = MaterialTheme.colorScheme.onSecondary, text = if (pinExists) { stringResource(id = R.string.enter_pin) @@ -29,7 +32,7 @@ fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) { }, ) }, - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.secondary, onPinCorrect = { // pin is correct, navigate or hide pin lock if (context.isRunningOnTv()) { @@ -43,13 +46,13 @@ fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) { }, onPinIncorrect = { // pin is incorrect, show error - appViewModel.showSnackbarMessage( + snackbar.showMessage( StringValue.StringResource(R.string.incorrect_pin).asString(context), ) }, onPinCreated = { // pin created for the first time, navigate or hide pin lock - appViewModel.showSnackbarMessage( + snackbar.showMessage( StringValue.StringResource(R.string.pin_created).asString(context), ) appViewModel.onPinLockEnabled() 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 40a7b9f..f538cb0 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 @@ -45,7 +45,6 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,21 +66,23 @@ import com.google.accompanist.permissions.rememberPermissionState import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState +import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.AppViewModel import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDisclosure import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog -import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings +import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.showToast -import kotlinx.coroutines.launch import xyz.teamgravity.pin_lock_compose.PinManager @OptIn( @@ -92,16 +93,17 @@ import xyz.teamgravity.pin_lock_compose.PinManager fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, + uiState: AppUiState, navController: NavController, focusRequester: FocusRequester, ) { val context = LocalContext.current val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() + val snackbar = SnackbarController.current val scrollState = rememberScrollState() val interactionSource = remember { MutableInteractionSource() } + val isRunningOnTv = context.isRunningOnTv() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() val kernelSupport by viewModel.kernelSupport.collectAsStateWithLifecycle() val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) @@ -120,6 +122,10 @@ fun SettingsScreen( viewModel.checkKernelSupport() } + LaunchedEffect(uiState.settings.trustedNetworkSSIDs) { + currentText = "" + } + val notificationPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) @@ -166,9 +172,9 @@ fun SettingsScreen( } fun handleAutoTunnelToggle() { - if (!uiState.isBatteryOptimizeDisableShown || !isBatteryOptimizationsDisabled()) return requestBatteryOptimizationsDisabled() + if (!uiState.generalState.isBatteryOptimizationDisableShown || !isBatteryOptimizationsDisabled()) return requestBatteryOptimizationsDisabled() if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) { - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.notification_permission_required), ) return notificationPermissionState.launchPermissionRequest() @@ -184,11 +190,7 @@ fun SettingsScreen( fun saveTrustedSSID() { if (currentText.isNotEmpty()) { - viewModel.onSaveTrustedSSID(currentText).onSuccess { - currentText = "" - }.onFailure { - appViewModel.showSnackbarMessage(it.getMessage(context)) - } + viewModel.onSaveTrustedSSID(currentText) } } @@ -202,13 +204,9 @@ fun SettingsScreen( } } - fun onRootDenied() = appViewModel.showSnackbarMessage(context.getString(R.string.error_root_denied)) - - fun onRootAccepted() = appViewModel.showSnackbarMessage(context.getString(R.string.root_accepted)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if ( - context.isRunningOnTv() && + isRunningOnTv && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q ) { checkFineLocationGranted() @@ -228,7 +226,7 @@ fun SettingsScreen( if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { checkFineLocationGranted() } - if (!uiState.isLocationDisclosureShown) { + if (!uiState.generalState.isLocationDisclosureShown) { BackgroundLocationDisclosure( onDismiss = { viewModel.setLocationDisclosureShown() }, onAttest = { @@ -259,410 +257,376 @@ fun SettingsScreen( AuthorizationPrompt( onSuccess = { showAuthPrompt = false - scope.launch { - viewModel.exportAllConfigs().onSuccess { - appViewModel.showSnackbarMessage(context.getString(R.string.exported_configs_message)) - }.onFailure { - appViewModel.showSnackbarMessage(context.getString(R.string.export_configs_failed)) - } - } + viewModel.exportAllConfigs() }, onError = { _ -> showAuthPrompt = false - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.error_authentication_failed), ) }, onFailure = { showAuthPrompt = false - appViewModel.showSnackbarMessage( + snackbar.showMessage( context.getString(R.string.error_authorization_failed), ) }, ) } - if (uiState.isLocationDisclosureShown) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .clickable( + indication = null, + interactionSource = interactionSource, + ) { + focusManager.clearFocus() + }, + ) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, modifier = - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .clickable( - indication = null, - interactionSource = interactionSource, - ) { - focusManager.clearFocus() - }, + ( + if (isRunningOnTv) { + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth(fillMaxWidth) + .padding(top = 10.dp) + } else { + Modifier + .fillMaxWidth(fillMaxWidth) + .padding(top = 20.dp) + } + ) + .padding(bottom = 10.dp), ) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - ( - if (context.isRunningOnTv()) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp), + ) { + SectionTitle( + title = stringResource(id = R.string.auto_tunneling), + padding = screenPadding, + ) + ConfigurationToggle( + stringResource(id = R.string.tunnel_on_wifi), + enabled = !uiState.settings.isAlwaysOnVpnEnabled, + checked = uiState.settings.isTunnelOnWifiEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnWifi() }, + modifier = + if (uiState.settings.isAutoTunnelEnabled) { Modifier - .height(IntrinsicSize.Min) - .fillMaxWidth(fillMaxWidth) - .padding(top = 10.dp) } else { Modifier - .fillMaxWidth(fillMaxWidth) - .padding(top = 20.dp) - } - ) - .padding(bottom = 10.dp), - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp), - ) { - SectionTitle( - title = stringResource(id = R.string.auto_tunneling), - padding = screenPadding, - ) - ConfigurationToggle( - stringResource(id = R.string.tunnel_on_wifi), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled + .focusRequester(focusRequester) + }, + ) + if (uiState.settings.isTunnelOnWifiEnabled) { + Column { + FlowRow( + modifier = + Modifier + .padding(screenPadding) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + uiState.settings.trustedNetworkSSIDs.forEach { ssid -> + ClickableIconButton( + onClick = { + if (isRunningOnTv) { + focusRequester.requestFocus() + viewModel.onDeleteTrustedSSID(ssid) + } + }, + onIconClick = { + if (isRunningOnTv) focusRequester.requestFocus() + viewModel.onDeleteTrustedSSID(ssid) + }, + text = ssid, + icon = Icons.Filled.Close, + ) + } + if (uiState.settings.trustedNetworkSSIDs.isEmpty()) { + Text( + stringResource(R.string.none), + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + OutlinedTextField( + value = currentText, + onValueChange = { currentText = it }, + label = { Text(stringResource(R.string.add_trusted_ssid)) }, + modifier = + Modifier + .padding( + start = screenPadding, + top = 5.dp, + bottom = 10.dp, + ), + supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } }, + maxLines = 1, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, ), - checked = uiState.settings.isTunnelOnWifiEnabled, - padding = screenPadding, - onCheckChanged = { viewModel.onToggleTunnelOnWifi() }, - modifier = - if (uiState.settings.isAutoTunnelEnabled) { + keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }), + trailingIcon = { + if (currentText != "") { + IconButton(onClick = { saveTrustedSSID() }) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = + if (currentText == "") { + stringResource( + id = + R.string + .trusted_ssid_empty_description, + ) + } else { + stringResource( + id = + R.string + .trusted_ssid_value_description, + ) + }, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) + } + } + ConfigurationToggle( + stringResource(R.string.tunnel_mobile_data), + enabled = !uiState.settings.isAlwaysOnVpnEnabled, + checked = uiState.settings.isTunnelOnMobileDataEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnMobileData() }, + ) + ConfigurationToggle( + stringResource(id = R.string.tunnel_on_ethernet), + enabled = !uiState.settings.isAlwaysOnVpnEnabled, + checked = uiState.settings.isTunnelOnEthernetEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleTunnelOnEthernet() }, + ) + ConfigurationToggle( + stringResource(R.string.restart_on_ping), + checked = uiState.settings.isPingEnabled, + padding = screenPadding, + onCheckChanged = { viewModel.onToggleRestartOnPing() }, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + ( + if (!uiState.settings.isAutoTunnelEnabled) { Modifier } else { - Modifier - .focusRequester(focusRequester) - }, - ) - if (uiState.settings.isTunnelOnWifiEnabled) { - Column { - FlowRow( - modifier = - Modifier - .padding(screenPadding) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - uiState.settings.trustedNetworkSSIDs.forEach { ssid -> - ClickableIconButton( - onClick = { - if (context.isRunningOnTv()) { - focusRequester.requestFocus() - viewModel.onDeleteTrustedSSID(ssid) - } - }, - onIconClick = { - if (context.isRunningOnTv()) focusRequester.requestFocus() - viewModel.onDeleteTrustedSSID(ssid) - }, - text = ssid, - icon = Icons.Filled.Close, - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled - ), - ) - } - if (uiState.settings.trustedNetworkSSIDs.isEmpty()) { - Text( - stringResource(R.string.none), - fontStyle = FontStyle.Italic, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - OutlinedTextField( - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled - ), - value = currentText, - onValueChange = { currentText = it }, - label = { Text(stringResource(R.string.add_trusted_ssid)) }, - modifier = - Modifier - .padding( - start = screenPadding, - top = 5.dp, - bottom = 10.dp, - ), - maxLines = 1, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }), - trailingIcon = { - if (currentText != "") { - IconButton(onClick = { saveTrustedSSID() }) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = - if (currentText == "") { - stringResource( - id = - R.string - .trusted_ssid_empty_description, - ) - } else { - stringResource( - id = - R.string - .trusted_ssid_value_description, - ) - }, - tint = MaterialTheme.colorScheme.primary, - ) - } - } - }, + Modifier.focusRequester( + focusRequester, ) } + ) + .fillMaxSize() + .padding(top = 5.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton( + onClick = { + if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required) + if ( + uiState.settings.isTunnelOnWifiEnabled && + !uiState.settings.isAutoTunnelEnabled + ) { + when (false) { + isBackgroundLocationGranted -> showLocationDialog = true + fineLocationState.status.isGranted -> showLocationDialog = true + viewModel.isLocationEnabled(context) -> + showLocationServicesAlertDialog = true + + else -> { + handleAutoTunnelToggle() + } + } + } else { + handleAutoTunnelToggle() + } + }, + ) { + val autoTunnelButtonText = + if (uiState.settings.isAutoTunnelEnabled) { + stringResource(R.string.disable_auto_tunnel) + } else { + stringResource(id = R.string.enable_auto_tunnel) + } + Text(autoTunnelButtonText) } + } + } + } + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .fillMaxWidth(fillMaxWidth) + .padding(vertical = 10.dp), + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp), + ) { + SectionTitle( + title = stringResource(id = R.string.backend), + padding = screenPadding, + ) + ConfigurationToggle( + stringResource(R.string.use_amnezia), + enabled = + !( + uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled || + (uiState.vpnState.status == TunnelState.UP) || uiState.settings.isKernelEnabled + ), + checked = uiState.settings.isAmneziaEnabled, + padding = screenPadding, + onCheckChanged = { + viewModel.onToggleAmnezia() + }, + ) + ConfigurationToggle( + stringResource(R.string.use_kernel), + enabled = + !( + uiState.settings.isAutoTunnelEnabled || + uiState.settings.isAlwaysOnVpnEnabled || + (uiState.vpnState.status == TunnelState.UP) || + kernelSupport + ), + checked = uiState.settings.isKernelEnabled, + padding = screenPadding, + onCheckChanged = { + viewModel.onToggleKernelMode() + }, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxSize() + .padding(top = 5.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton( + onClick = { + viewModel.onRequestRoot() + }, + ) { + Text(stringResource(R.string.request_root)) + } + } + } + } + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .fillMaxWidth(fillMaxWidth) + .padding(vertical = 10.dp) + .padding(bottom = 10.dp), + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp), + ) { + SectionTitle( + title = stringResource(id = R.string.other), + padding = screenPadding, + ) + if (!isRunningOnTv) { ConfigurationToggle( - stringResource(R.string.tunnel_mobile_data), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled + stringResource(R.string.always_on_vpn_support), + enabled = !( + uiState.settings.isTunnelOnWifiEnabled || + uiState.settings.isTunnelOnWifiEnabled || + uiState.settings.isTunnelOnMobileDataEnabled ), - checked = uiState.settings.isTunnelOnMobileDataEnabled, + checked = uiState.settings.isAlwaysOnVpnEnabled, padding = screenPadding, - onCheckChanged = { viewModel.onToggleTunnelOnMobileData() }, + onCheckChanged = { viewModel.onToggleAlwaysOnVPN() }, ) ConfigurationToggle( - stringResource(id = R.string.tunnel_on_ethernet), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled - ), - checked = uiState.settings.isTunnelOnEthernetEnabled, + stringResource(R.string.enabled_app_shortcuts), + enabled = true, + checked = uiState.settings.isShortcutsEnabled, padding = screenPadding, - onCheckChanged = { viewModel.onToggleTunnelOnEthernet() }, - ) - ConfigurationToggle( - stringResource(R.string.restart_on_ping), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled - ), - checked = uiState.settings.isPingEnabled, - padding = screenPadding, - onCheckChanged = { viewModel.onToggleRestartOnPing() }, + onCheckChanged = { viewModel.onToggleShortcutsEnabled() }, ) + } + ConfigurationToggle( + stringResource(R.string.restart_at_boot), + enabled = true, + checked = uiState.settings.isRestoreOnBootEnabled, + padding = screenPadding, + onCheckChanged = { + viewModel.onToggleRestartAtBoot() + }, + ) + ConfigurationToggle( + stringResource(R.string.enable_app_lock), + enabled = true, + checked = uiState.generalState.isPinLockEnabled, + padding = screenPadding, + onCheckChanged = { + if (uiState.generalState.isPinLockEnabled) { + appViewModel.onPinLockDisabled() + } else { + // TODO may want to show a dialog before proceeding in the future + PinManager.initialize(WireGuardAutoTunnel.instance) + navController.navigate(Screen.Lock.route) + } + }, + ) + if (!isRunningOnTv) { Row( verticalAlignment = Alignment.CenterVertically, modifier = - ( - if (!uiState.settings.isAutoTunnelEnabled) { - Modifier - } else { - Modifier.focusRequester( - focusRequester, - ) - } - ) + Modifier .fillMaxSize() .padding(top = 5.dp), horizontalArrangement = Arrangement.Center, ) { TextButton( - enabled = !uiState.settings.isAlwaysOnVpnEnabled, + enabled = !didExportFiles, onClick = { if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required) - if ( - uiState.settings.isTunnelOnWifiEnabled && - !uiState.settings.isAutoTunnelEnabled - ) { - when (false) { - isBackgroundLocationGranted -> showLocationDialog = true - fineLocationState.status.isGranted -> showLocationDialog = true - viewModel.isLocationEnabled(context) -> - showLocationServicesAlertDialog = true - - else -> { - handleAutoTunnelToggle() - } - } - } else { - handleAutoTunnelToggle() - } + showAuthPrompt = true }, ) { - val autoTunnelButtonText = - if (uiState.settings.isAutoTunnelEnabled) { - stringResource(R.string.disable_auto_tunnel) - } else { - stringResource(id = R.string.enable_auto_tunnel) - } - Text(autoTunnelButtonText) - } - } - } - } - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .fillMaxWidth(fillMaxWidth) - .padding(vertical = 10.dp), - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp), - ) { - SectionTitle( - title = stringResource(id = R.string.backend), - padding = screenPadding, - ) - ConfigurationToggle( - stringResource(R.string.use_amnezia), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled || - (uiState.vpnState.status == TunnelState.UP) || uiState.settings.isKernelEnabled - ), - checked = uiState.settings.isAmneziaEnabled, - padding = screenPadding, - onCheckChanged = { - viewModel.onToggleAmnezia() - }, - ) - if (kernelSupport) { - ConfigurationToggle( - stringResource(R.string.use_kernel), - enabled = - !( - uiState.settings.isAutoTunnelEnabled || - uiState.settings.isAlwaysOnVpnEnabled || - (uiState.vpnState.status == TunnelState.UP) - ), - checked = uiState.settings.isKernelEnabled, - padding = screenPadding, - onCheckChanged = { - scope.launch { - viewModel.onToggleKernelMode({ onRootAccepted() }, { onRootDenied() }) - } - }, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton( - onClick = { - viewModel.requestRoot({ onRootAccepted() }, { onRootDenied() }) - }, - ) { - Text(stringResource(R.string.request_root)) - } - } - } - } - } - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .fillMaxWidth(fillMaxWidth) - .padding(vertical = 10.dp) - .padding(bottom = 10.dp), - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.padding(15.dp), - ) { - SectionTitle( - title = stringResource(id = R.string.other), - padding = screenPadding, - ) - if (!context.isRunningOnTv()) { - ConfigurationToggle( - stringResource(R.string.always_on_vpn_support), - enabled = !uiState.settings.isAutoTunnelEnabled, - checked = uiState.settings.isAlwaysOnVpnEnabled, - padding = screenPadding, - onCheckChanged = { viewModel.onToggleAlwaysOnVPN() }, - ) - ConfigurationToggle( - stringResource(R.string.enabled_app_shortcuts), - enabled = true, - checked = uiState.settings.isShortcutsEnabled, - padding = screenPadding, - onCheckChanged = { viewModel.onToggleShortcutsEnabled() }, - ) - } - ConfigurationToggle( - stringResource(R.string.restart_at_boot), - enabled = true, - checked = uiState.settings.isRestoreOnBootEnabled, - padding = screenPadding, - onCheckChanged = { - viewModel.onToggleRestartAtBoot() - }, - ) - ConfigurationToggle( - stringResource(R.string.enable_app_lock), - enabled = true, - checked = uiState.isPinLockEnabled, - padding = screenPadding, - onCheckChanged = { - if (uiState.isPinLockEnabled) { - appViewModel.onPinLockDisabled() - } else { - // TODO may want to show a dialog before proceeding in the future - PinManager.initialize(WireGuardAutoTunnel.instance) - navController.navigate(Screen.Lock.route) - } - }, - ) - if (!context.isRunningOnTv()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton( - enabled = !didExportFiles, - onClick = { - if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required) - showAuthPrompt = true - }, - ) { - Text(stringResource(R.string.export_configs)) - } + Text(stringResource(R.string.export_configs)) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt deleted file mode 100644 index 9adcf85..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsUiState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.settings - -import com.zaneschepke.wireguardautotunnel.data.domain.Settings -import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig -import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState - -data class SettingsUiState( - val settings: Settings = Settings(), - val tunnels: List = emptyList(), - val vpnState: VpnState = VpnState(), - val isLocationDisclosureShown: Boolean = true, - val isBatteryOptimizeDisableShown: Boolean = false, - val isPinLockEnabled: Boolean = false, -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt index 97e7fd9..7eaf6dd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -7,25 +7,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager -import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService -import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.util.FileUtils -import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions +import com.zaneschepke.wireguardautotunnel.util.StringValue import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber import java.io.File import javax.inject.Inject import javax.inject.Provider @@ -35,45 +33,29 @@ class SettingsViewModel @Inject constructor( private val appDataRepository: AppDataRepository, - private val serviceManager: ServiceManager, private val rootShell: Provider, private val fileUtils: FileUtils, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - tunnelService: TunnelService, ) : ViewModel() { + private val _kernelSupport = MutableStateFlow(false) val kernelSupport = _kernelSupport.asStateFlow() + private val settings = appDataRepository.settings.getSettingsFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) - val uiState = - combine( - appDataRepository.settings.getSettingsFlow(), - appDataRepository.tunnels.getTunnelConfigsFlow(), - tunnelService.vpnState, - appDataRepository.appState.generalStateFlow, - ) { settings, tunnels, tunnelState, generalState -> - SettingsUiState( - settings, - tunnels, - tunnelState, - generalState.isLocationDisclosureShown, - generalState.isBatteryOptimizationDisableShown, - generalState.isPinLockEnabled, - ) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - SettingsUiState(), - ) - - fun onSaveTrustedSSID(ssid: String): Result { + fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch { val trimmed = ssid.trim() - return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) { - uiState.value.settings.trustedNetworkSSIDs.add(trimmed) - saveSettings(uiState.value.settings) - Result.success(Unit) - } else { - Result.failure(WgTunnelExceptions.SsidConflict()) + with(settings.value) { + if (!trustedNetworkSSIDs.contains(trimmed)) { + this.trustedNetworkSSIDs.add(ssid) + appDataRepository.settings.save(this) + } else { + SnackbarController.showMessage( + StringValue.StringResource( + R.string.error_ssid_exists, + ), + ) + } } } @@ -85,61 +67,70 @@ constructor( appDataRepository.appState.setBatteryOptimizationDisableShown(true) } - fun onToggleTunnelOnMobileData() { - saveSettings( - uiState.value.settings.copy( - isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled, - ), - ) + fun onToggleTunnelOnMobileData() = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + copy( + isTunnelOnMobileDataEnabled = !this.isTunnelOnMobileDataEnabled, + ), + ) + } } - fun onDeleteTrustedSSID(ssid: String) { - saveSettings( - uiState.value.settings.copy( - trustedNetworkSSIDs = - (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(), - ), - ) + fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + copy( + trustedNetworkSSIDs = (this.trustedNetworkSSIDs - ssid).toMutableList(), + ), + ) + } } - suspend fun onExportTunnels(files: List): Result { - return fileUtils.saveFilesToZip(files) + private fun exportTunnels(files: List) = viewModelScope.launch { + fileUtils.saveFilesToZip(files).onSuccess { + SnackbarController.showMessage(StringValue.StringResource(R.string.exported_configs_message)) + }.onFailure { + SnackbarController.showMessage(StringValue.StringResource(R.string.export_configs_failed)) + } } fun onToggleAutoTunnel(context: Context) = viewModelScope.launch { - val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled - var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused - - if (isAutoTunnelEnabled) { - serviceManager.stopWatcherService(context) - } else { - serviceManager.startWatcherService(context) - isAutoTunnelPaused = false + with(settings.value) { + var isAutoTunnelPaused = this.isAutoTunnelPaused + if (isAutoTunnelEnabled) { + ServiceManager.stopWatcherService(context) + } else { + ServiceManager.startWatcherService(context) + isAutoTunnelPaused = false + } + appDataRepository.settings.save( + copy( + isAutoTunnelEnabled = !isAutoTunnelEnabled, + isAutoTunnelPaused = isAutoTunnelPaused, + ), + ) } - saveSettings( - uiState.value.settings.copy( - isAutoTunnelEnabled = !isAutoTunnelEnabled, - isAutoTunnelPaused = isAutoTunnelPaused, - ), - ) } fun onToggleAlwaysOnVPN() = viewModelScope.launch { - saveSettings( - uiState.value.settings.copy( - isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, - ), - ) + with(settings.value) { + appDataRepository.settings.save( + copy( + isAlwaysOnVpnEnabled = !isAlwaysOnVpnEnabled, + ), + ) + } } - private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) } - - fun onToggleTunnelOnEthernet() { - saveSettings( - uiState.value.settings.copy( - isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled, - ), - ) + fun onToggleTunnelOnEthernet() = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + copy( + isTunnelOnEthernetEnabled = !isTunnelOnEthernetEnabled, + ), + ) + } } fun isLocationEnabled(context: Context): Boolean { @@ -150,73 +141,74 @@ constructor( return LocationManagerCompat.isLocationEnabled(locationManager) } - fun onToggleShortcutsEnabled() { - saveSettings( - uiState.value.settings.copy( - isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled, - ), - ) + fun onToggleShortcutsEnabled() = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + this.copy( + isShortcutsEnabled = !isShortcutsEnabled, + ), + ) + } } - private fun saveKernelMode(enabled: Boolean) { - saveSettings( - uiState.value.settings.copy( - isKernelEnabled = enabled, - ), - ) + private fun saveKernelMode(enabled: Boolean) = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + this.copy( + isKernelEnabled = enabled, + ), + ) + } } - fun onToggleTunnelOnWifi() { - saveSettings( - uiState.value.settings.copy( - isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled, - ), - ) + fun onToggleTunnelOnWifi() = viewModelScope.launch { + with(settings.value) { + appDataRepository.settings.save( + copy( + isTunnelOnWifiEnabled = !isTunnelOnWifiEnabled, + ), + ) + } } fun onToggleAmnezia() = viewModelScope.launch { - if (uiState.value.settings.isKernelEnabled) { - saveKernelMode(false) + with(settings.value) { + if (isKernelEnabled) { + saveKernelMode(false) + } + appDataRepository.settings.save( + copy( + isAmneziaEnabled = !isAmneziaEnabled, + ), + ) } - saveAmneziaMode(!uiState.value.settings.isAmneziaEnabled) } - private fun saveAmneziaMode(on: Boolean) { - saveSettings( - uiState.value.settings.copy( - isAmneziaEnabled = on, - ), - ) - } - - fun onToggleKernelMode(onSuccess: () -> Unit, onFailure: () -> Unit) = viewModelScope.launch { - if (!uiState.value.settings.isKernelEnabled) { - requestRoot( - { - onSuccess() - saveSettings( - uiState.value.settings.copy( + fun onToggleKernelMode() = viewModelScope.launch { + with(settings.value) { + if (!isKernelEnabled) { + requestRoot().onSuccess { + appDataRepository.settings.save( + copy( isKernelEnabled = true, isAmneziaEnabled = false, ), ) - }, - { - onFailure() - saveKernelMode(enabled = false) - }, - ) - } else { - saveKernelMode(enabled = false) + } + } else { + saveKernelMode(enabled = false) + } } } fun onToggleRestartOnPing() = viewModelScope.launch { - saveSettings( - uiState.value.settings.copy( - isPingEnabled = !uiState.value.settings.isPingEnabled, - ), - ) + with(settings.value) { + appDataRepository.settings.save( + copy( + isPingEnabled = !isPingEnabled, + ), + ) + } } fun checkKernelSupport() = viewModelScope.launch { @@ -230,31 +222,36 @@ constructor( } fun onToggleRestartAtBoot() = viewModelScope.launch { - saveSettings( - uiState.value.settings.copy( - isRestoreOnBootEnabled = !uiState.value.settings.isRestoreOnBootEnabled, - ), - ) - } - - fun requestRoot(onSuccess: () -> Unit, onFailure: () -> Unit) = viewModelScope.launch(ioDispatcher) { - kotlin.runCatching { - rootShell.get().start() - Timber.i("Root shell accepted!") - onSuccess() - }.onFailure { - onFailure() - }.onSuccess { - onSuccess() + with(settings.value) { + appDataRepository.settings.save( + copy( + isRestoreOnBootEnabled = !isRestoreOnBootEnabled, + ), + ) } } - suspend fun exportAllConfigs(): Result { - return kotlin.runCatching { + private suspend fun requestRoot(): Result { + return withContext(ioDispatcher) { + kotlin.runCatching { + rootShell.get().start() + SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted)) + }.onFailure { + SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied)) + } + } + } + + fun onRequestRoot() = viewModelScope.launch { + requestRoot() + } + + fun exportAllConfigs() = viewModelScope.launch { + kotlin.runCatching { val tunnels = appDataRepository.tunnels.getAll() val wgFiles = fileUtils.createWgFiles(tunnels) val amFiles = fileUtils.createAmFiles(tunnels) - onExportTunnels(wgFiles + amFiles) + exportTunnels(wgFiles + amFiles) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/WildcardSupportingLabel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/WildcardSupportingLabel.kt new file mode 100644 index 0000000..ec6986b --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/WildcardSupportingLabel.kt @@ -0,0 +1,44 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import com.zaneschepke.wireguardautotunnel.R + +@Composable +fun WildcardSupportingLabel(onClick: (url: String) -> Unit) { + // TODO update link when docs are fully updated + val gettingStarted = + buildAnnotatedString { + pushStringAnnotation( + tag = "details", + annotation = stringResource(id = R.string.docs_features), + ) + withStyle( + style = SpanStyle(color = MaterialTheme.colorScheme.primary), + ) { + append(stringResource(id = R.string.wildcard_supported)) + } + pop() + } + ClickableText( + text = gettingStarted, + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start, + fontStyle = FontStyle.Italic, + ), + ) { + gettingStarted.getStringAnnotations(tag = "details", it, it) + .firstOrNull()?.let { annotation -> + onClick(annotation.item) + } + } +} 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 be87dae..d13b1da 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 @@ -27,7 +27,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -43,23 +42,20 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.zaneschepke.wireguardautotunnel.BuildConfig import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.AppUiState import com.zaneschepke.wireguardautotunnel.ui.Screen import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl @Composable -fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), navController: NavController, focusRequester: FocusRequester) { +fun SupportScreen(navController: NavController, focusRequester: FocusRequester, appUiState: AppUiState) { val context = LocalContext.current val fillMaxWidth = .85f - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, @@ -301,7 +297,7 @@ fun SupportScreen(viewModel: SupportViewModel = hiltViewModel(), navController: buildAnnotatedString { append(stringResource(R.string.mode)) append(": ") - when (uiState.settings.isKernelEnabled) { + when (appUiState.settings.isKernelEnabled) { true -> append(stringResource(id = R.string.kernel)) false -> append(stringResource(id = R.string.userspace)) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt deleted file mode 100644 index 9484409..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportUiState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.support - -import com.zaneschepke.wireguardautotunnel.data.domain.Settings - -data class SupportUiState(val settings: Settings = Settings()) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt deleted file mode 100644 index 6874a3b..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportViewModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.support - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository -import com.zaneschepke.wireguardautotunnel.util.Constants -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@HiltViewModel -class SupportViewModel -@Inject -constructor(settingsRepository: SettingsRepository) : - ViewModel() { - val uiState = - settingsRepository - .getSettingsFlow() - .map { SupportUiState(it) } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - SupportUiState(), - ) -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelExceptions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelExceptions.kt index afd38f4..dd3dee8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelExceptions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelExceptions.kt @@ -6,24 +6,6 @@ import com.zaneschepke.wireguardautotunnel.R sealed class WgTunnelExceptions : Exception() { abstract fun getMessage(context: Context): String - data class General(private val userMessage: StringValue) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class SsidConflict( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.error_ssid_exists, - ), - ) : - WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - data class ConfigExportFailed( private val userMessage: StringValue = StringValue.StringResource( @@ -44,18 +26,6 @@ sealed class WgTunnelExceptions : Exception() { } } - data class RootDenied( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.error_root_denied, - ), - ) : - WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - data class InvalidQrCode( private val userMessage: StringValue = StringValue.StringResource( @@ -90,70 +60,4 @@ sealed class WgTunnelExceptions : Exception() { return userMessage.asString(context) } } - - data class AuthenticationFailed( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.error_authentication_failed, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class AuthorizationFailed( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.error_authorization_failed, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class BackgroundLocationRequired( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.background_location_required, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class LocationServicesRequired( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.location_services_required, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class PreciseLocationRequired( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.precise_location_required, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } - - data class FileExplorerRequired( - private val userMessage: StringValue = - StringValue.StringResource( - R.string.error_no_file_explorer, - ), - ) : WgTunnelExceptions() { - override fun getMessage(context: Context): String { - return userMessage.asString(context) - } - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt index d56586d..ecbc3bd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/CoroutineExtensions.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.util.extensions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ReceiveChannel @@ -76,3 +77,16 @@ fun CoroutineScope.asChannel(flow: Flow): ReceiveChannel = produce { channel.send(value) } } + +fun Job?.onNotRunning(callback: () -> Unit) { + if (this == null || this.isCompleted || this.isCompleted) { + callback.invoke() + } +} + +fun Job.cancelWithMessage(message: String) { + kotlin.runCatching { + this.cancel() + Timber.i(message) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt new file mode 100644 index 0000000..786e32d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt @@ -0,0 +1,30 @@ +package com.zaneschepke.wireguardautotunnel.util.extensions + +import timber.log.Timber +import java.util.regex.Pattern + +fun String.isValidIpv4orIpv6Address(): Boolean { + val ipv4Pattern = Pattern.compile( + "^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$", + ) + val ipv6Pattern = Pattern.compile( + "^([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\$", + ) + return ipv4Pattern.matcher(this).matches() || ipv6Pattern.matcher(this).matches() +} + +fun List.isMatchingToWildcardList(value: String): Boolean { + val excludeValues = this.filter { it.startsWith("!") }.map { it.removePrefix("!").toRegexWithWildcards() } + Timber.d("Excluded values: $excludeValues") + val includedValues = this.filter { !it.startsWith("!") }.map { it.toRegexWithWildcards() } + Timber.d("Included values: $includedValues") + val matches = includedValues.filter { it.matches(value) } + val excludedMatches = excludeValues.filter { it.matches(value) } + Timber.d("Excluded matches: $excludedMatches") + Timber.d("Matches: $matches") + return matches.isNotEmpty() && excludedMatches.isEmpty() +} + +fun String.toRegexWithWildcards(): Regex { + return this.replace("*", ".*").replace("?", ".").toRegex() +} 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 0b86cc2..1e1bd44 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 @@ -1,10 +1,13 @@ package com.zaneschepke.wireguardautotunnel.util.extensions +import com.wireguard.config.Peer import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.NumberUtils import org.amnezia.awg.config.Config +import timber.log.Timber +import java.net.InetAddress fun TunnelStatistics.mapPeerStats(): Map { return this.getPeers().associateWith { key -> (this.peerStats(key)) } @@ -28,6 +31,23 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus { } } +fun Peer.isReachable(): Boolean { + val host = + if (this.endpoint.isPresent && + this.endpoint.get().resolved.isPresent + ) { + this.endpoint.get().resolved.get().host + } else { + Constants.DEFAULT_PING_IP + } + Timber.i("Checking reachability of peer: $host") + val reachable = + InetAddress.getByName(host) + .isReachable(Constants.PING_TIMEOUT.toInt()) + Timber.i("Result: reachable - $reachable") + return reachable +} + fun Config.toWgQuickString(): String { val amQuick = toAwgQuickString(true) val lines = amQuick.lines().toMutableList() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0b2dfd..5cd3497 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Watcher Notification Channel https://github.com/zaneschepke/wgtunnel/issues https://zaneschepke.com/wgtunnel-docs/overview.html + https://zaneschepke.com/wgtunnel-docs/features.html https://zaneschepke.com/wgtunnel-docs/privacypolicy.html File is not a .conf or .zip Action requires tunnel off @@ -186,4 +187,11 @@ app settings to make sure these permissions are enabled. Root shell accepted + Set custom ping ip + (optional, defaults to peers) + Ping interval (sec) + "optional, default: " + Ping restart cooldown (sec) + Learn about supported wildcards. + details diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt index 07881d6..86afb18 100644 --- a/buildSrc/src/main/kotlin/Constants.kt +++ b/buildSrc/src/main/kotlin/Constants.kt @@ -1,7 +1,7 @@ object Constants { const val VERSION_NAME = "3.5.1" const val JVM_TARGET = "17" - const val VERSION_CODE = 35102 + const val VERSION_CODE = 35103 const val TARGET_SDK = 34 const val MIN_SDK = 26 const val APP_ID = "com.zaneschepke.wireguardautotunnel" @@ -19,5 +19,5 @@ object Constants { const val TYPE = "type" const val NIGHTLY_CODE = 42 - const val PRERELEASE_CODE = 53 + const val PRERELEASE_CODE = 54 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 348f6a5..aece179 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -accompanist = "0.34.0" -activityCompose = "1.9.1" +accompanist = "0.36.0" +activityCompose = "1.9.2" amneziawgAndroid = "1.2.2" androidx-junit = "1.2.1" appcompat = "1.7.0" @@ -8,25 +8,25 @@ biometricKtx = "1.2.0-alpha05" coreGoogleShortcuts = "1.1.0" coreKtx = "1.13.1" datastorePreferences = "1.1.1" -desugar_jdk_libs = "2.0.4" +desugar_jdk_libs = "2.1.2" espressoCore = "3.6.1" hiltAndroid = "2.52" hiltNavigationCompose = "1.2.0" junit = "4.13.2" -kotlinx-serialization-json = "1.7.1" -lifecycle-runtime-compose = "2.8.4" -material3 = "1.2.1" +kotlinx-serialization-json = "1.7.2" +lifecycle-runtime-compose = "2.8.5" +material3 = "1.3.0" multifabVersion = "1.1.1" -navigationCompose = "2.7.7" +navigationCompose = "2.8.0" pinLockCompose = "1.0.3" roomVersion = "2.6.1" timber = "5.0.1" -tunnel = "1.2.3" +tunnel = "1.2.4" androidGradlePlugin = "8.6.0" kotlin = "2.0.20" ksp = "2.0.20-1.0.24" -composeBom = "2024.08.00" -compose = "1.6.8" +composeBom = "2024.09.00" +compose = "1.7.0" zxingAndroidEmbedded = "4.3.0" coreSplashscreen = "1.0.1" gradlePlugins-grgit = "5.2.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c861f8e..ac7bef3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,7 +2,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d +distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists