From a873546e9e75b2e6d91f6bd71b6d480fa17697ce Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Sun, 30 Mar 2025 18:31:26 -0400 Subject: [PATCH] fix: bugs in config changes and ping tunnel jobs (#650) --- .../wireguardautotunnel/MainActivity.kt | 17 +- .../core/service/TunnelForegroundService.kt | 108 ++- .../core/tunnel/BaseTunnel.kt | 220 ++--- .../core/tunnel/KernelTunnel.kt | 63 +- .../core/tunnel/TunnelManager.kt | 2 +- .../core/tunnel/TunnelProvider.kt | 2 +- .../core/tunnel/UserspaceTunnel.kt | 73 +- .../wireguardautotunnel/di/TunnelModule.kt | 6 +- .../domain/entity/TunnelConf.kt | 82 +- .../wireguardautotunnel/ui/Route.kt | 6 +- .../ui/common/animation/ShimmerEffect.kt | 37 + .../ui/screens/main/ConfigScreen.kt | 808 ------------------ .../ui/screens/main/SplitTunnelScreen.kt | 269 ------ .../ui/screens/main/config/ConfigScreen.kt | 108 +++ .../ui/screens/main/config/ConfigViewModel.kt | 172 ++++ .../main/config/components/AddPeerButton.kt | 28 + .../config/components/InterfaceDropdown.kt | 72 ++ .../main/config/components/InterfaceFields.kt | 220 +++++ .../config/components/InterfaceSection.kt | 74 ++ .../main/config/components/PeerFields.kt | 73 ++ .../main/config/components/PeersSection.kt | 102 +++ .../main/config/state/ConfigUiState.kt | 16 + .../main/splittunnel/SplitTunnelScreen.kt | 60 ++ .../main/splittunnel/SplitTunnelSkeleton.kt | 111 +++ .../main/splittunnel/SplitTunnelViewModel.kt | 164 ++++ .../splittunnel/components/AppListItem.kt | 59 ++ .../splittunnel/components/AppListSection.kt | 77 ++ .../components/SplitOptionsSelector.kt | 79 ++ .../components/SplitTunnelContent.kt | 42 + .../main/splittunnel/state/SplitOption.kt} | 4 +- .../splittunnel/state/SplitTunnelUiState.kt | 11 + .../main/splittunnel/state/TunnelApp.kt | 8 + .../ui/state/ConfigProxy.kt | 36 + .../ui/state/SplitTunnelApp.kt | 9 - .../util/extensions/TunnelExtensions.kt | 15 +- .../viewmodel/AppViewModel.kt | 142 +-- app/src/main/res/values/strings.xml | 3 + 37 files changed, 1821 insertions(+), 1557 deletions(-) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/ConfigScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/SplitTunnelScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelSkeleton.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/{enums/SplitOptions.kt => screens/main/splittunnel/state/SplitOption.kt} (88%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelApp.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt index 5cd43540..19068ae1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -51,12 +51,12 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider -import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.OptionsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.ScannerScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.main.SplitTunnelScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.TunnelAutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AdvancedScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen @@ -125,10 +125,6 @@ class MainActivity : AppCompatActivity() { } } - LaunchedEffect(Unit) { - viewModel.getEmitSplitTunnelApps(this@MainActivity) - } - with(appUiState.appSettings) { LaunchedEffect(isShortcutsEnabled) { if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts() @@ -224,7 +220,7 @@ class MainActivity : AppCompatActivity() { val args = backStack.toRoute() val config = appUiState.tunnels.firstOrNull { it.id == args.id } - ConfigScreen(config, viewModel) + ConfigScreen(config) } composable { backStack -> val args = backStack.toRoute() @@ -241,11 +237,8 @@ class MainActivity : AppCompatActivity() { composable { KillSwitchScreen(appUiState, viewModel) } - composable { backStack -> - val args = backStack.toRoute() - appUiState.tunnels.firstOrNull { it.id == args.id }?.let { - SplitTunnelScreen(it, viewModel) - } + composable { + SplitTunnelScreen() } composable { backStack -> val args = backStack.toRoute() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt index 7bdcf21f..45975a29 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt @@ -5,14 +5,30 @@ import android.content.Intent import android.os.IBinder import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import com.zaneschepke.networkmonitor.NetworkMonitor +import com.zaneschepke.networkmonitor.NetworkStatus import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.util.Constants import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint @@ -24,6 +40,18 @@ class TunnelForegroundService : LifecycleService() { @Inject lateinit var serviceManager: ServiceManager + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + @IoDispatcher + lateinit var ioDispatcher: CoroutineDispatcher + + @Inject + lateinit var tunnelRepo: TunnelRepository + + private val isNetworkConnected = MutableStateFlow(true) + override fun onCreate() { super.onCreate() serviceManager.backgroundService.complete(this) @@ -40,12 +68,85 @@ class TunnelForegroundService : LifecycleService() { } fun start(tunnelConf: TunnelConf) { + Timber.d("Service starting with TunnelConf instance: ${tunnelConf.hashCode()}") ServiceCompat.startForeground( this@TunnelForegroundService, NotificationManager.KERNEL_SERVICE_NOTIFICATION_ID, createNotification(tunnelConf), Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID, ) + // monitor if we have internet connectivity + startNetworkMonitorJob() + + // job to trigger stats emit on interval + startTunnelStatsJob(tunnelConf) + + // monitor changes to the tunnel config + startTunnelConfChangesJob(tunnelConf) + + startPingJob(tunnelConf) + } + + private fun startTunnelConfChangesJob(tunnelConf: TunnelConf) = lifecycleScope.launch(ioDispatcher) { + tunnelRepo.flow + .flowOn(ioDispatcher) + .map { storedTunnels -> + storedTunnels.firstOrNull { it.id == tunnelConf.id } + } + .filterNotNull() + // only emit when one of these 3 values change + .distinctUntilChanged { old, new -> + old.tunName == new.tunName && old.wgQuick == new.wgQuick && old.amQuick == new.amQuick + } + .collect { storedTunnel -> + if (tunnelConf.isTunnelConfigChanged(storedTunnel)) { + Timber.d("Config changed for ${storedTunnel.tunName}, bouncing") + tunnelConf.bounceTunnel(storedTunnel) + } + } + } + + private fun startNetworkMonitorJob() = lifecycleScope.launch(ioDispatcher) { + networkMonitor.networkStatusFlow + .flowOn(ioDispatcher) + .collectLatest { status -> + val isAvailable = status !is NetworkStatus.Disconnected + isNetworkConnected.value = isAvailable + Timber.d("Network available: $status") + } + } + + private fun startTunnelStatsJob(tunnel: TunnelConf) = lifecycleScope.launch(ioDispatcher) { + while (isActive) { + tunnel.onUpdateStatistics() + delay(STATS_DELAY) + } + } + + private fun startPingJob(tunnel: TunnelConf) = lifecycleScope.launch(ioDispatcher) { + delay(PING_START_DELAY) + while (isActive) { + val shouldBounce = shouldBounceTunnel(tunnel) + val delayMs = if (shouldBounce) { + tunnel.bounceTunnel(tunnel) + tunnel.pingCooldown ?: Constants.PING_COOLDOWN + } else { + tunnel.pingInterval ?: Constants.PING_INTERVAL + } + delay(delayMs) + } + } + + private suspend fun shouldBounceTunnel(tunnel: TunnelConf): Boolean { + if (!isNetworkConnected.value) { + Timber.d("Network disconnected, skipping ping for ${tunnel.tunName}") + return false + } + return runCatching { + !tunnel.isTunnelPingable(ioDispatcher) + }.onFailure { e -> + Timber.e(e, "Ping check failed for ${tunnel.tunName}") + }.getOrDefault(true) } fun stop() { @@ -67,4 +168,9 @@ class TunnelForegroundService : LifecycleService() { ), ) } + + companion object { + const val STATS_DELAY = 1_000L + const val PING_START_DELAY = 30_000L + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt index d10a7f28..16662589 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt @@ -2,8 +2,6 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.Tunnel -import com.zaneschepke.networkmonitor.NetworkMonitor -import com.zaneschepke.networkmonitor.NetworkStatus import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager @@ -18,90 +16,45 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController -import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState -import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber -import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean abstract class BaseTunnel( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val applicationScope: CoroutineScope, - private val networkMonitor: NetworkMonitor, private val appDataRepository: AppDataRepository, private val serviceManager: ServiceManager, private val notificationManager: NotificationManager, ) : TunnelProvider { - companion object { - const val CHECK_INTERVAL = 1000L - } - - protected val activeTuns = MutableStateFlow>(emptyMap()) + private val activeTuns = MutableStateFlow>(emptyMap()) override val activeTunnels = activeTuns.asStateFlow() - private val tunnelJobs = ConcurrentHashMap>() + private val isBounce = AtomicBoolean(false) - protected val mutex = Mutex() - private val isNetworkConnected = MutableStateFlow(true) + private val mutex = Mutex() - init { - applicationScope.launch(ioDispatcher) { - launch { monitorNetworkStatus() } - launch { monitorTunnelConfigChanges() } - } - } + abstract suspend fun startBackend(tunnel: TunnelConf) - private fun startTunnelJobs(tunnel: TunnelConf): Job { - return applicationScope.launch(ioDispatcher) { - val jobs = mutableListOf() - jobs += launch { updateTunnelStatistics(tunnel) } - if (tunnel.isPingEnabled) jobs += launch { monitorTunnelPing(tunnel) } - jobs.forEach { it.join() } - } - } + abstract fun stopBackend(tunnel: TunnelConf) - private suspend fun updateTunnelStatistics(tunnel: TunnelConf) { - while (true) { - runCatching { - val stats = getStatistics(tunnel) - updateTunnelState(tunnel, stats = stats) - }.onFailure { e -> - Timber.e(e, "Failed to update stats for ${tunnel.tunName}") - } - delay(CHECK_INTERVAL) - } - } + private fun findActiveTunnel(id: Int): TunnelConf? = activeTuns.value.keys.find { it.id == id } - private suspend fun monitorTunnelPing(tunnel: TunnelConf) { - while (true) { - runCatching { - if (isNetworkConnected.value && tunnel.isActive) { - val pingSuccess = tunnel.isTunnelPingable(ioDispatcher) - if (!pingSuccess) bounceTunnel(tunnel) - } - }.onFailure { e -> - Timber.e(e, "Ping failed for ${tunnel.tunName}") - } - delay(tunnel.pingInterval ?: Constants.PING_INTERVAL) - } - } + private fun isTunnelActive(id: Int) = (activeTuns.value.any { it.key.id == id }) - protected fun handleBackendThrowable(throwable: Throwable) { + private fun handleBackendThrowable(throwable: Throwable) { val backendError = when (throwable) { is BackendException -> throwable.toBackendError() is org.amnezia.awg.backend.BackendException -> throwable.toBackendError() @@ -127,7 +80,7 @@ abstract class BaseTunnel( } } - protected fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) { + private fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) { applicationScope.launch(ioDispatcher) { mutex.withLock { activeTuns.update { current -> @@ -135,7 +88,6 @@ abstract class BaseTunnel( val existingState = current.getValueById(tunnelConf.id) ?: TunnelState() val newState = state ?: existingState.state if (newState == TunnelStatus.DOWN) { - // Remove tunnel from activeTunnels when it goes DOWN Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN") current - originalConf } else if (existingState.state == newState && stats == null) { @@ -153,107 +105,109 @@ abstract class BaseTunnel( } } - protected suspend fun configureTunnel(tunnelConf: TunnelConf) { - // setup state change callback + protected suspend fun stopActiveTunnels() { + activeTunnels.value.forEach { (config, state) -> + if (state.state.isUp()) { + stopTunnel(config) + delay(300) + } + } + } + + private fun configureTunnelCallbacks(tunnelConf: TunnelConf) { + Timber.d("Configuring TunnelConf instance: ${tunnelConf.hashCode()}") + tunnelConf.setStateChangeCallback { state -> Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}") when (state) { is Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState()) is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState()) } - applicationScope.launch(ioDispatcher) { serviceManager.updateTunnelTile() } + serviceManager.updateTunnelTile() } - - activeTuns.update { current -> - current.filter { it.key != tunnelConf } + (tunnelConf to TunnelState()) + tunnelConf.setTunnelStatsCallback { + val stats = getStatistics(tunnelConf) + updateTunnelState(tunnelConf, null, stats) } - } - - protected suspend fun onStartSuccess(tunnelConf: TunnelConf) { - val tunnelCopy = tunnelConf.copyWithCallback(isActive = true) - - // start service - if (activeTuns.value.isEmpty()) { - serviceManager.startTunnelForegroundService(tunnelCopy) - } else { - serviceManager.updateTunnelForegroundServiceNotification(tunnelCopy) - } - // save active - appDataRepository.tunnels.save(tunnelCopy) - // start tunnel jobs - tunnelJobs[tunnelCopy.id] = mutableListOf(startTunnelJobs(tunnelConf)) + tunnelConf.setBounceTunnelCallback(::bounceTunnel) } override fun startTunnel(tunnelConf: TunnelConf) { - throw NotImplementedError("Must be implemented by subclass") + applicationScope.launch(ioDispatcher) { + runCatching { + if (isTunnelActive(tunnelConf.id)) return@launch + startTunnelInner(tunnelConf) + }.onFailure { exception -> + Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace") + stopTunnel(tunnelConf) + handleBackendThrowable(exception) + }.onSuccess { + Timber.i("Tunnel ${tunnelConf.id} started successfully") + } + } + } + + private suspend fun startTunnelInner(tunnelConf: TunnelConf) { + mutex.withLock { + configureTunnelCallbacks(tunnelConf) + startBackend(tunnelConf) + saveTunnelActiveState(tunnelConf, true) + if (!isBounce.get()) serviceManager.startTunnelForegroundService(tunnelConf) + } + } + + private suspend fun saveTunnelActiveState(tunnelConf: TunnelConf, active: Boolean) { + val tunnelCopy = tunnelConf.copyWithCallback(isActive = active) + appDataRepository.tunnels.save(tunnelCopy) } override fun stopTunnel(tunnelConf: TunnelConf?) { - tunnelConf?.let { - applicationScope.launch(ioDispatcher) { - mutex.withLock { - removeActiveTunnel(tunnelConf) - tunnelJobs[tunnelConf.id]?.forEach { it.cancelWithMessage("Cancel tunnel job") } - tunnelJobs.remove(tunnelConf.id) - val lockedConf = it.copyWithCallback(isActive = false) - appDataRepository.tunnels.save(lockedConf) - - // TODO improve to handle multiple tunnels - if (activeTuns.value.isEmpty()) { - Timber.d("No tunnels active, stopping background service") - serviceManager.stopTunnelForegroundService() - } else { - Timber.d("Other tunnels still active, updating service notification") - val nextActive = activeTuns.value.keys.firstOrNull() - if (nextActive != null) { - Timber.d("Next active tunnel: ${nextActive.id}") - serviceManager.updateTunnelForegroundServiceNotification(nextActive) - } - } - } + applicationScope.launch(ioDispatcher) { + runCatching { + if (tunnelConf == null) return@launch stopActiveTunnels() + stopTunnelInner(tunnelConf) + }.onFailure { e -> + Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}") } } } + private suspend fun stopTunnelInner(tunnelConf: TunnelConf) { + mutex.withLock { + val tunnel = findActiveTunnel(tunnelConf.id) ?: return + stopBackend(tunnel) + removeActiveTunnel(tunnel) + // use latest tunnel + saveTunnelActiveState(tunnelConf, false) + handleServiceChangesOnStop() + } + } + + private fun handleServiceChangesOnStop() { + if (activeTuns.value.isEmpty() && !isBounce.get()) return serviceManager.stopTunnelForegroundService() + val nextActive = activeTuns.value.keys.firstOrNull() + if (nextActive != null) { + Timber.d("Next active tunnel: ${nextActive.id}") + serviceManager.updateTunnelForegroundServiceNotification(nextActive) + } + } + private fun removeActiveTunnel(tunnelConf: TunnelConf) { activeTuns.update { current -> current.toMutableMap().apply { remove(tunnelConf) } } } - override suspend fun bounceTunnel(tunnelConf: TunnelConf) { - stopTunnel(tunnelConf) - delay(1000) - startTunnel(tunnelConf) - } - - private suspend fun monitorNetworkStatus() { - networkMonitor.networkStatusFlow - .flowOn(ioDispatcher) - .collectLatest { status -> - val isAvailable = status !is NetworkStatus.Disconnected - isNetworkConnected.value = isAvailable - Timber.d("Network status: $isAvailable") - } - } - - private suspend fun monitorTunnelConfigChanges() { - appDataRepository.tunnels.flow.collectLatest { storedTunnels -> - mutex.withLock { - storedTunnels.forEach { stored -> - val current = activeTuns.value.keys.find { it.id == stored.id } - if (current != null && !current.isQuickConfigMatching(stored)) { - Timber.d("Config changed for ${stored.id}, bouncing") - bounceTunnel(stored) - } - } - } + override fun bounceTunnel(tunnelConf: TunnelConf) { + applicationScope.launch(ioDispatcher) { + Timber.i("Bounce tunnel ${tunnelConf.name}") + isBounce.set(true) + stopTunnel(tunnelConf) + delay(300) + startTunnel(tunnelConf) + isBounce.set(false) } } - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { - throw NotImplementedError("Must be implemented by subclass") - } - override suspend fun runningTunnelNames(): Set = activeTuns.value.keys.map { it.tunName }.toSet() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt index 884e4c97..3a53811b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt @@ -2,22 +2,17 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Tunnel -import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics -import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject @@ -28,36 +23,7 @@ class KernelTunnel @Inject constructor( appDataRepository: AppDataRepository, notificationManager: NotificationManager, private val backend: Backend, - networkMonitor: NetworkMonitor, -) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { - - override fun startTunnel(tunnelConf: TunnelConf) { - Timber.i("Starting tunnel ${tunnelConf.id} kernel") - applicationScope.launch(ioDispatcher) { - runCatching { - // tunnel already active - if (activeTuns.value.any { it.key.id == tunnelConf.id }) return@launch - - mutex.withLock { - updateTunnelState(tunnelConf, TunnelStatus.STARTING) - - // configure state callback and add to tunnels - configureTunnel(tunnelConf) - - updateTunnelState(tunnelConf, backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig()).asTunnelState()) - - // run some actions after start success - onStartSuccess(tunnelConf) - } - }.onFailure { exception -> - Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} kernel") - stopTunnel(tunnelConf) - handleBackendThrowable(exception) - }.onSuccess { - Timber.i("Tunnel ${tunnelConf.id} started successfully") - } - } - } +) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) { override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { return try { @@ -68,26 +34,13 @@ class KernelTunnel @Inject constructor( } } - override fun stopTunnel(tunnelConf: TunnelConf?) { - applicationScope.launch(ioDispatcher) { - runCatching { - val originalTunnel = activeTuns.value.keys.find { it.id == tunnelConf?.id } - if (originalTunnel != null) { - Timber.i("Stopping tunnel ${originalTunnel.id} kernel") - mutex.withLock { - updateTunnelState(originalTunnel, backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toWgConfig()).asTunnelState()) - super.stopTunnel(originalTunnel) - } - } else { - Timber.w("Tunnel not found in startedTunnels, stopping all tunnels") - activeTuns.value.keys.forEach { config -> - stopTunnel(config) - } - } - }.onFailure { e -> - Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}") - } - } + override suspend fun startBackend(tunnel: TunnelConf) { + backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig()) + } + + override fun stopBackend(tunnel: TunnelConf) { + Timber.i("Stopping tunnel ${tunnel.id} kernel") + backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig()) } override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt index 00304bd7..00abb777 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt @@ -64,7 +64,7 @@ class TunnelManager @Inject constructor( tunnelProviderFlow.value.stopTunnel(tunnelConf) } - override suspend fun bounceTunnel(tunnelConf: TunnelConf) { + override fun bounceTunnel(tunnelConf: TunnelConf) { tunnelProviderFlow.value.bounceTunnel(tunnelConf) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt index 7fd0be9f..a2af9d17 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.StateFlow interface TunnelProvider { fun startTunnel(tunnelConf: TunnelConf) fun stopTunnel(tunnelConf: TunnelConf? = null) - suspend fun bounceTunnel(tunnelConf: TunnelConf) + fun bounceTunnel(tunnelConf: TunnelConf) suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) suspend fun runningTunnelNames(): Set fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt index 6f2cf7d3..3e4c892c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt @@ -1,23 +1,17 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel -import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState -import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState -import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withLock import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Tunnel import timber.log.Timber @@ -30,69 +24,16 @@ class UserspaceTunnel @Inject constructor( appDataRepository: AppDataRepository, notificationManager: NotificationManager, private val backend: Backend, - networkMonitor: NetworkMonitor, -) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { +) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) { - override fun startTunnel(tunnelConf: TunnelConf) { - Timber.i("Starting tunnel ${tunnelConf.id} userspace") - applicationScope.launch(ioDispatcher) { - runCatching { - // tunnel already active - if (activeTuns.value.any { it.key.id == tunnelConf.id }) return@launch - - // stop any active tunnels that aren't this one, userspace only - stopActiveTunnels() - - mutex.withLock { - updateTunnelState(tunnelConf, TunnelStatus.STARTING) - - // configure state callback and add to tunnels - configureTunnel(tunnelConf) - - updateTunnelState(tunnelConf, backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig()).asTunnelState()) - - // run some actions after start success - onStartSuccess(tunnelConf) - } - }.onFailure { exception -> - Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace") - stopTunnel(tunnelConf) - handleBackendThrowable(exception) - }.onSuccess { - Timber.i("Tunnel ${tunnelConf.id} started successfully") - } - } + override suspend fun startBackend(tunnel: TunnelConf) { + stopActiveTunnels() + backend.setState(tunnel, Tunnel.State.UP, tunnel.toAmConfig()) } - private suspend fun stopActiveTunnels() { - activeTunnels.value.forEach { (config, state) -> - if (state.state.isUp()) { - stopTunnel(config) - delay(300) - } - } - } - - override fun stopTunnel(tunnelConf: TunnelConf?) { - applicationScope.launch(ioDispatcher) { - runCatching { - val originalTunnel = activeTuns.value.keys.find { it.id == tunnelConf?.id } - if (originalTunnel != null) { - Timber.i("Stopping tunnel ${originalTunnel.id} userspace") - mutex.withLock { - updateTunnelState(originalTunnel, backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toAmConfig()).asTunnelState()) - super.stopTunnel(originalTunnel) - } - } else { - Timber.w("Tunnel not found in startedTunnels, stopping all tunnels") - activeTuns.value.keys.forEach { config -> - stopTunnel(config) - } - } - }.onFailure { e -> - Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}") - } - } + override fun stopBackend(tunnel: TunnelConf) { + Timber.i("Stopping tunnel ${tunnel.id} userspace") + backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig()) } override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt index 86353315..d392e380 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/TunnelModule.kt @@ -67,11 +67,10 @@ class TunnelModule { @ApplicationScope applicationScope: CoroutineScope, serviceManager: ServiceManager, appDataRepository: AppDataRepository, - networkMonitor: NetworkMonitor, notificationManager: NotificationManager, backend: com.wireguard.android.backend.Backend, ): TunnelProvider { - return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend, networkMonitor) + return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend) } @Provides @@ -83,10 +82,9 @@ class TunnelModule { serviceManager: ServiceManager, appDataRepository: AppDataRepository, notificationManager: NotificationManager, - networkMonitor: NetworkMonitor, backend: Backend, ): TunnelProvider { - return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend, networkMonitor) + return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend) } @Provides diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt index 35a4be12..5ee60608 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt @@ -5,15 +5,12 @@ import com.wireguard.config.Config import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.io.InputStream -import java.net.InetAddress -import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.withContext import timber.log.Timber +import java.io.InputStream +import java.net.InetAddress import java.nio.charset.StandardCharsets +import kotlin.coroutines.CoroutineContext data class TunnelConf( val id: Int = 0, @@ -32,14 +29,22 @@ data class TunnelConf( val isIpv4Preferred: Boolean = true, @Transient private var stateChangeCallback: ((Any) -> Unit)? = null, + @Transient + private var tunnelStatsCallback: (() -> Unit)? = null, + @Transient + private var bounceTunnelCallback: ((TunnelConf) -> Unit)? = null, ) : Tunnel, org.amnezia.awg.backend.Tunnel { - private val callbackMutex = Mutex() + fun setStateChangeCallback(callback: (Any) -> Unit) { + stateChangeCallback = callback + } - suspend fun setStateChangeCallback(callback: (Any) -> Unit) { - callbackMutex.withLock { - stateChangeCallback = callback - } + fun setTunnelStatsCallback(callback: (() -> Unit)) { + tunnelStatsCallback = callback + } + + fun setBounceTunnelCallback(callback: (TunnelConf) -> Unit) { + bounceTunnelCallback = callback } fun copyWithCallback( @@ -64,9 +69,19 @@ data class TunnelConf( isEthernetTunnel, isIpv4Preferred, ).apply { stateChangeCallback = this@TunnelConf.stateChangeCallback + tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback + bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback } } + fun onUpdateStatistics() { + tunnelStatsCallback?.invoke() + } + + fun bounceTunnel(tunnelConf: TunnelConf) { + bounceTunnelCallback?.invoke(tunnelConf) + } + fun toAmConfig(): org.amnezia.awg.config.Config { return configFromAmQuick(amQuick.ifBlank { wgQuick }) } @@ -80,42 +95,15 @@ data class TunnelConf( override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) { - Timber.d("onStateChange called for tunnel $id: $tunName with state $newState") - runBlocking { - callbackMutex.withLock { - if (stateChangeCallback != null) { - Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState") - stateChangeCallback?.invoke(newState) - } else { - Timber.w("No stateChangeCallback set for tunnel $id: $tunName") - } - } - } + stateChangeCallback?.invoke(newState) } override fun onStateChange(newState: Tunnel.State) { - Timber.d("onStateChange called for tunnel $id: $tunName with state $newState") - runBlocking { - callbackMutex.withLock { - if (stateChangeCallback != null) { - Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState") - stateChangeCallback?.invoke(newState) - } else { - Timber.w("No stateChangeCallback set for tunnel $id: $tunName") - } - } - } + stateChangeCallback?.invoke(newState) } - fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean { - return updatedConf.wgQuick == wgQuick || updatedConf.amQuick == amQuick - } - - fun isPingConfigMatching(updatedConf: TunnelConf): Boolean { - return updatedConf.isPingEnabled == isPingEnabled && - pingIp == updatedConf.pingIp && - updatedConf.pingCooldown == pingCooldown && - updatedConf.pingInterval == pingInterval + fun isTunnelConfigChanged(updatedConf: TunnelConf): Boolean { + return updatedConf.wgQuick != wgQuick || updatedConf.amQuick != amQuick || updatedConf.name != name } suspend fun isTunnelPingable(context: CoroutineContext): Boolean { @@ -123,11 +111,15 @@ data class TunnelConf( val config = toWgConfig() if (pingIp != null) { return@withContext InetAddress.getByName(pingIp) - .isReachable(Constants.PING_TIMEOUT.toInt()) + .isReachable(Constants.PING_TIMEOUT.toInt()).also { + Timber.i("Ping reachable $pingIp: $it") + } } config.peers.map { peer -> - peer.isReachable(isIpv4Preferred) - }.all { true } + peer.isReachable() + }.all { true }.also { + Timber.i("Ping of all peers reachable: $it") + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt index dd817f44..e97c641a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt @@ -52,7 +52,11 @@ sealed class Route { @Serializable data class SplitTunnel( val id: Int, - ) : Route() + ) : Route() { + companion object { + const val KEY_ID = "id" + } + } @Serializable data class TunnelAutoTunnel( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt new file mode 100644 index 00000000..2655a071 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/animation/ShimmerEffect.kt @@ -0,0 +1,37 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.animation + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +fun ShimmerEffect(modifier: Modifier = Modifier): Brush { + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.9f), + Color.LightGray.copy(alpha = 0.3f), + Color.LightGray.copy(alpha = 0.9f), + ) + + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + ), + ) + + return Brush.linearGradient( + colors = shimmerColors, + start = Offset(0f, 0f), + end = Offset(translateAnim, translateAnim), + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/ConfigScreen.kt deleted file mode 100644 index f16a6358..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/ConfigScreen.kt +++ /dev/null @@ -1,808 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar -import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt -import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController -import com.zaneschepke.wireguardautotunnel.ui.enums.InterfaceActions -import com.zaneschepke.wireguardautotunnel.ui.enums.PeerActions -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy -import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize -import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight -import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth -import org.amnezia.awg.crypto.KeyPair - -@Composable -fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel) { - val context = LocalContext.current - val snackbar = SnackbarController.current - val clipboardManager: ClipboardManager = LocalClipboardManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val navController = LocalNavController.current - - var isInterfaceDropDownExpanded by remember { - mutableStateOf(false) - } - - val popBackStack by appViewModel.popBackStack.collectAsStateWithLifecycle(false) - - val configPair = Pair(tunnelConf?.tunName ?: "", tunnelConf?.toAmConfig()) - - var tunnelName by remember { - mutableStateOf(configPair.first) - } - - var interfaceState by remember { - mutableStateOf(configPair.second?.let { InterfaceProxy.from(it.`interface`) } ?: InterfaceProxy()) - } - - var showAmneziaValues by remember { - mutableStateOf(configPair.second?.`interface`?.junkPacketCount?.isPresent == true) - } - - var showScripts by remember { - mutableStateOf(false) - } - - val peersState = remember { - (configPair.second?.peers?.map { PeerProxy.from(it) } ?: listOf(PeerProxy())).toMutableStateList() - } - - var showAuthPrompt by remember { mutableStateOf(false) } - var isAuthenticated by remember { mutableStateOf(false) } - - val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) - val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - - if (showAuthPrompt) { - AuthorizationPrompt( - onSuccess = { - showAuthPrompt = false - isAuthenticated = true - }, - onError = { - showAuthPrompt = false - snackbar.showMessage( - context.getString(R.string.error_authentication_failed), - ) - }, - onFailure = { - showAuthPrompt = false - snackbar.showMessage( - context.getString(R.string.error_authorization_failed), - ) - }, - ) - } - - LaunchedEffect(popBackStack) { - if (popBackStack) navController.popBackStack() - } - - Scaffold( - topBar = { - TopNavBar(stringResource(R.string.edit_tunnel), trailing = { - IconButton(onClick = { - tunnelConf?.let { - appViewModel.updateExistingTunnelConfig( - it, - tunnelName, - peersState, - interfaceState, - ) - } ?: appViewModel.saveNewTunnel(tunnelName, peersState, interfaceState) - }) { - val icon = Icons.Outlined.Save - Icon( - imageVector = icon, - contentDescription = icon.name, - ) - } - }) - }, - ) { padding -> - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 24.dp.scaledHeight()) - .padding(horizontal = 24.dp.scaledWidth()), - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), - modifier = Modifier - .padding(16.dp.scaledWidth()) - .focusGroup(), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth(), - ) { - GroupLabel( - stringResource(R.string.interface_), - ) - Column { - IconButton( - modifier = Modifier.size(iconSize), - onClick = { - isInterfaceDropDownExpanded = true - }, - ) { - val icon = Icons.Rounded.MoreVert - Icon(icon, icon.name) - } - DropdownMenu( - containerColor = MaterialTheme.colorScheme.surface, - expanded = isInterfaceDropDownExpanded, - modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), - onDismissRequest = { - isInterfaceDropDownExpanded = false - }, - ) { - val isAmneziaCompatibilitySet = interfaceState.isAmneziaCompatibilityModeSet() - InterfaceActions.entries.forEach { action -> - DropdownMenuItem( - text = { - Text( - text = when (action) { - InterfaceActions.TOGGLE_SHOW_SCRIPTS -> if (showScripts) { - stringResource(R.string.hide_scripts) - } else { - stringResource(R.string.show_scripts) - } - InterfaceActions.TOGGLE_AMNEZIA_VALUES -> if (showAmneziaValues) { - stringResource(R.string.hide_amnezia_properties) - } else { - stringResource(R.string.show_amnezia_properties) - } - - InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) { - stringResource(R.string.remove_amnezia_compatibility) - } else { - stringResource(R.string.enable_amnezia_compatibility) - } - }, - ) - }, - onClick = { - isInterfaceDropDownExpanded = false - when (action) { - InterfaceActions.TOGGLE_AMNEZIA_VALUES -> showAmneziaValues = !showAmneziaValues - InterfaceActions.TOGGLE_SHOW_SCRIPTS -> showScripts = !showScripts - InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) { - showAmneziaValues = false - interfaceState = interfaceState.resetAmneziaProperties() - } else { - showAmneziaValues = true - interfaceState = interfaceState.toAmneziaCompatibilityConfig() - } - } - }, - ) - } - } - } - } - ConfigurationTextBox( - value = tunnelName, - onValueChange = { tunnelName = it }, - keyboardActions = keyboardActions, - label = stringResource(R.string.name), - hint = stringResource(R.string.tunnel_name).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - val privateKeyEnabled = (tunnelConf == null) || isAuthenticated - OutlinedTextField( - textStyle = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .fillMaxWidth() - .clickable { showAuthPrompt = true }, - value = interfaceState.privateKey, - visualTransformation = - if (privateKeyEnabled) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - enabled = privateKeyEnabled, - onValueChange = { interfaceState = interfaceState.copy(privateKey = it) }, - trailingIcon = { - IconButton( - enabled = privateKeyEnabled, - modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize), - onClick = { - val keypair = KeyPair() - interfaceState = interfaceState.copy( - privateKey = keypair.privateKey.toBase64(), - publicKey = keypair.publicKey.toBase64(), - ) - }, - ) { - Icon( - Icons.Rounded.Refresh, - stringResource(R.string.rotate_keys), - tint = if (privateKeyEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, - ) - } - }, - label = { - Text( - stringResource(R.string.private_key), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, - ) - }, - singleLine = true, - placeholder = { - Text( - stringResource(R.string.base64_key), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.outline, - ) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - OutlinedTextField( - textStyle = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(FocusRequester.Default), - value = interfaceState.publicKey, - enabled = false, - onValueChange = { - interfaceState = interfaceState.copy(publicKey = it) - }, - trailingIcon = { - IconButton( - modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize), - onClick = { - clipboardManager.setText( - AnnotatedString(interfaceState.publicKey), - ) - }, - ) { - Icon( - Icons.Rounded.ContentCopy, - stringResource(R.string.copy_public_key), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - }, - label = { - Text( - stringResource(R.string.public_key), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, - ) - }, - singleLine = true, - placeholder = { - Text( - stringResource(R.string.base64_key), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.outline, - ) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - ConfigurationTextBox( - value = interfaceState.addresses, - onValueChange = { - interfaceState = interfaceState.copy(addresses = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.addresses), - hint = stringResource(R.string.comma_separated_list), - modifier = - Modifier - .fillMaxWidth() - .padding(end = 5.dp), - ) - ConfigurationTextBox( - value = interfaceState.listenPort, - onValueChange = { - interfaceState = interfaceState.copy(listenPort = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.listen_port), - hint = stringResource(R.string.random), - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - ConfigurationTextBox( - value = interfaceState.dnsServers, - onValueChange = { - interfaceState = interfaceState.copy(dnsServers = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.dns_servers), - hint = stringResource(R.string.comma_separated_list), - modifier = - Modifier - .fillMaxWidth(3 / 5f) - .padding(end = 5.dp), - ) - ConfigurationTextBox( - value = interfaceState.mtu, - onValueChange = { - interfaceState = interfaceState.copy(mtu = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.mtu), - hint = stringResource(R.string.auto), - modifier = Modifier.width(IntrinsicSize.Min), - ) - } - if (showScripts) { - ConfigurationTextBox( - value = interfaceState.preUp, - onValueChange = { - interfaceState = interfaceState.copy(preUp = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.pre_up), - hint = stringResource(R.string.comma_separated_list).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.postUp, - onValueChange = { - interfaceState = interfaceState.copy(postUp = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.post_up), - hint = stringResource(R.string.comma_separated_list).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.preDown, - onValueChange = { - interfaceState = interfaceState.copy(preDown = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.pre_down), - hint = stringResource(R.string.comma_separated_list).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.postDown, - onValueChange = { - interfaceState = interfaceState.copy(postDown = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.post_down), - hint = stringResource(R.string.comma_separated_list).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - } - if (showAmneziaValues) { - ConfigurationTextBox( - value = interfaceState.junkPacketCount, - onValueChange = { - interfaceState = interfaceState.copy(junkPacketCount = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_count), - hint = stringResource(R.string.junk_packet_count).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.junkPacketMinSize, - onValueChange = { - interfaceState = interfaceState.copy(junkPacketMinSize = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_minimum_size), - hint = - stringResource( - R.string.junk_packet_minimum_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.junkPacketMaxSize, - onValueChange = { - interfaceState = interfaceState.copy(junkPacketMaxSize = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_maximum_size), - hint = - stringResource( - R.string.junk_packet_maximum_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.initPacketJunkSize, - onValueChange = { - interfaceState = interfaceState.copy(initPacketJunkSize = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.init_packet_junk_size), - hint = stringResource(R.string.init_packet_junk_size).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.responsePacketJunkSize, - onValueChange = { - interfaceState = interfaceState.copy(responsePacketJunkSize = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.response_packet_junk_size), - hint = - stringResource( - R.string.response_packet_junk_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.initPacketMagicHeader, - onValueChange = { - interfaceState = interfaceState.copy(initPacketMagicHeader = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.init_packet_magic_header), - hint = - stringResource( - R.string.init_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.responsePacketMagicHeader, - onValueChange = { - interfaceState = interfaceState.copy(responsePacketMagicHeader = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.response_packet_magic_header), - hint = - stringResource( - R.string.response_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.underloadPacketMagicHeader, - onValueChange = { - interfaceState = interfaceState.copy(underloadPacketMagicHeader = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.underload_packet_magic_header), - hint = - stringResource( - R.string.underload_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = interfaceState.transportPacketMagicHeader, - onValueChange = { - interfaceState = interfaceState.copy(transportPacketMagicHeader = it) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.transport_packet_magic_header), - hint = - stringResource( - R.string.transport_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - } - } - } - peersState.forEachIndexed { index, peer -> - var isPeerDropDownExpanded by remember { - mutableStateOf(false) - } - val isLanExcluded = peer.isLanExcluded() - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Top), - modifier = Modifier - .padding(16.dp.scaledWidth()) - .focusGroup(), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth(), - ) { - GroupLabel( - stringResource(R.string.peer), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - modifier = Modifier.size(iconSize), - onClick = { - // TODO make a dialog to confirm this - peersState.removeAt(index) - }, - ) { - val icon = Icons.Rounded.Delete - Icon(icon, icon.name) - } - Column { - IconButton( - modifier = Modifier.size(iconSize), - onClick = { - isPeerDropDownExpanded = true - }, - ) { - val icon = Icons.Rounded.MoreVert - Icon(icon, icon.name) - } - DropdownMenu( - containerColor = MaterialTheme.colorScheme.surface, - expanded = isPeerDropDownExpanded, - modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), - onDismissRequest = { - isPeerDropDownExpanded = false - }, - ) { - PeerActions.entries.forEach { action -> - DropdownMenuItem( - text = { - Text( - text = when (action) { - PeerActions.EXCLUDE_LAN -> if (isLanExcluded) { - stringResource(R.string.include_lan) - } else { - stringResource(R.string.exclude_lan) - } - }, - ) - }, - onClick = { - isPeerDropDownExpanded = false - when (action) { - PeerActions.EXCLUDE_LAN -> if (isLanExcluded) { - peersState[index] = peer.includeLan() - } else { - peersState[index] = peer.excludeLan() - } - } - }, - ) - } - } - } - } - } - - ConfigurationTextBox( - value = peer.publicKey, - onValueChange = { value -> - peersState[index] = peersState[index].copy(publicKey = value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.public_key), - hint = stringResource(R.string.base64_key), - modifier = Modifier.fillMaxWidth(), - ) - val presharedKeyEnabled = (tunnelConf == null) || isAuthenticated || - with(configPair.second?.peers?.getOrNull(index)?.preSharedKey) { this?.isEmpty == true || this?.isPresent == false } - OutlinedTextField( - textStyle = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .fillMaxWidth() - .clickable { showAuthPrompt = true }, - value = peer.preSharedKey, - visualTransformation = - if (presharedKeyEnabled) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - enabled = presharedKeyEnabled, - onValueChange = { value -> peersState[index] = peersState[index].copy(preSharedKey = value) }, - label = { - Text( - stringResource(R.string.preshared_key), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, - ) - }, - singleLine = true, - placeholder = { - Text( - stringResource(R.string.optional), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.outline, - ) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - OutlinedTextField( - textStyle = MaterialTheme.typography.labelLarge, - modifier = Modifier.fillMaxWidth(), - value = peer.persistentKeepalive, - enabled = true, - onValueChange = { value -> - peersState[index] = peersState[index].copy(persistentKeepalive = value) - }, - trailingIcon = { - Text( - stringResource(R.string.seconds), - modifier = Modifier.padding(end = 10.dp), - style = MaterialTheme.typography.labelMedium, - ) - }, - label = { - Text(stringResource(R.string.persistent_keepalive), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium) - }, - singleLine = true, - placeholder = { - Text(stringResource(R.string.optional_no_recommend), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - ConfigurationTextBox( - value = peer.endpoint, - onValueChange = { value -> - peersState[index] = peersState[index].copy(endpoint = value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.endpoint), - hint = stringResource(R.string.endpoint).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - textStyle = MaterialTheme.typography.labelLarge, - modifier = Modifier.fillMaxWidth(), - value = peer.allowedIps, - enabled = true, - onValueChange = { value -> - peersState[index] = peersState[index].copy(allowedIps = value) - }, - label = { - Text( - stringResource(R.string.allowed_ips), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium, - ) - }, - singleLine = true, - placeholder = { - Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - } - } - } - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(bottom = 140.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { - peersState.add(PeerProxy()) - }) { - Text(stringResource(R.string.add_peer)) - } - } - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/SplitTunnelScreen.kt deleted file mode 100644 index 4a31c3fc..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/SplitTunnelScreen.kt +++ /dev/null @@ -1,269 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.main - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MultiChoiceSegmentedButtonRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.drawablepainter.rememberDrawablePainter -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf -import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel -import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar -import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField -import com.zaneschepke.wireguardautotunnel.ui.enums.SplitOptions -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize -import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight -import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth -import java.text.Collator -import java.util.Locale - -@Composable -fun SplitTunnelScreen(tunnelConf: TunnelConf, viewModel: AppViewModel) { - val context = LocalContext.current - val navController = LocalNavController.current - - val inputHeight = 45.dp - - val collator = Collator.getInstance(Locale.getDefault()) - - val popBackStack by viewModel.popBackStack.collectAsStateWithLifecycle(false) - - LaunchedEffect(popBackStack) { - if (popBackStack) navController.popBackStack() - } - - val splitTunnelApps by viewModel.splitTunnelApps.collectAsStateWithLifecycle() - - var proxyInterface by remember { mutableStateOf(InterfaceProxy()) } - - var selectedSplitOption by remember { mutableStateOf(SplitOptions.ALL) } - - val selectedPackages = remember { mutableStateListOf() } - - LaunchedEffect(Unit) { - proxyInterface = InterfaceProxy.from(tunnelConf.toAmConfig().`interface`) - val pair = when { - proxyInterface.excludedApplications.isNotEmpty() -> Pair(SplitOptions.EXCLUDE, proxyInterface.excludedApplications) - proxyInterface.includedApplications.isNotEmpty() -> Pair(SplitOptions.INCLUDE, proxyInterface.includedApplications) - else -> Pair(SplitOptions.ALL, mutableSetOf()) - } - selectedSplitOption = pair.first - selectedPackages.addAll(pair.second) - } - - var query: String by remember { mutableStateOf("") } - - val sortedPackages by remember { - derivedStateOf { - splitTunnelApps.sortedWith(compareBy(collator) { it.name }).filter { it.name.lowercase().contains(query.lowercase()) }.toMutableStateList() - } - } - - LaunchedEffect(Unit) { - // clean up any split tunnel packages for apps that were uninstalled - viewModel.cleanUpUninstalledApps(tunnelConf, splitTunnelApps.map { it.`package` }) - } - - Scaffold( - topBar = { - TopNavBar(stringResource(R.string.tunneling_apps), trailing = { - IconButton(onClick = { - proxyInterface.apply { - includedApplications.clear() - excludedApplications.clear() - } - when (selectedSplitOption) { - SplitOptions.INCLUDE -> proxyInterface.includedApplications.apply { - addAll(selectedPackages) - } - SplitOptions.EXCLUDE -> proxyInterface.excludedApplications.apply { - addAll(selectedPackages) - } - SplitOptions.ALL -> Unit - } - viewModel.updateExistingTunnelConfig(tunnelConf, `interface` = proxyInterface) - }) { - val icon = Icons.Outlined.Save - Icon( - imageVector = icon, - contentDescription = icon.name, - ) - } - }) - }, - ) { padding -> - - Column( - verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(padding) - .padding(top = 24.dp.scaledHeight()), - ) { - MultiChoiceSegmentedButtonRow( - modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxWidth() - .padding(horizontal = 24.dp.scaledWidth()).height(inputHeight), - ) { - SplitOptions.entries.forEachIndexed { index, entry -> - val active = selectedSplitOption == entry - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape(index = index, count = SplitOptions.entries.size, baseShape = RoundedCornerShape(8.dp)), - icon = { - SegmentedButtonDefaults.Icon(active = active, activeContent = { - val icon = Icons.Outlined.Check - Icon(imageVector = icon, icon.name, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(SegmentedButtonDefaults.IconSize)) - }) { - Icon( - imageVector = entry.icon(), - contentDescription = entry.icon().name, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize), - ) - } - }, - colors = SegmentedButtonDefaults.colors().copy( - activeContainerColor = MaterialTheme.colorScheme.surface, - inactiveContainerColor = MaterialTheme.colorScheme.background, - ), - onCheckedChange = { - selectedSplitOption = entry - }, - checked = active, - ) { - Text( - entry.text().asString(context) - .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }, - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - if (selectedSplitOption != SplitOptions.ALL) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth(), - ) { - CustomTextField( - textStyle = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.onBackground, - ), - value = query, - onValueChange = { input -> - query = input - }, - interactionSource = remember { MutableInteractionSource() }, - label = {}, - leading = { - val icon = Icons.Outlined.Search - Icon(icon, icon.name) - }, - containerColor = MaterialTheme.colorScheme.background, - modifier = - Modifier - .fillMaxWidth().height(inputHeight).padding(horizontal = 24.dp.scaledWidth()), - singleLine = true, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(), - ) - LazyColumn( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - contentPadding = PaddingValues(top = 10.dp), - ) { - items(sortedPackages, key = { it.`package` }) { app -> - val checked = selectedPackages.contains(app.`package`) - val onClick = { - if (checked) selectedPackages.remove(app.`package`) else selectedPackages.add(app.`package`) - } - SelectionItemButton( - { - Image( - rememberDrawablePainter(app.icon), - app.name, - modifier = - Modifier - .padding(horizontal = 24.dp.scaledWidth()) - .size( - iconSize, - ), - ) - }, - buttonText = app.name, - onClick = { - onClick() - }, - trailing = { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = checked, - onCheckedChange = { - onClick() - }, - ) - } - }, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt new file mode 100644 index 00000000..7cd23825 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigScreen.kt @@ -0,0 +1,108 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar +import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.AddPeerButton +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun ConfigScreen(tunnelConf: TunnelConf?, viewModel: ConfigViewModel = hiltViewModel()) { + val context = LocalContext.current + val snackbar = SnackbarController.current + val keyboardController = LocalSoftwareKeyboardController.current + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(tunnelConf) { + viewModel.initFromTunnel(tunnelConf) + } + + LaunchedEffect(uiState.message) { + uiState.message?.let { message -> + snackbar.showMessage(message.asString(context)) + viewModel.setMessage(null) + } + } + + if (uiState.showAuthPrompt) { + AuthorizationPrompt( + onSuccess = { + viewModel.toggleShowAuthPrompt() + viewModel.onAuthenticated() + }, + onError = { + viewModel.toggleShowAuthPrompt() + snackbar.showMessage( + context.getString(R.string.error_authentication_failed), + ) + }, + onFailure = { + viewModel.toggleShowAuthPrompt() + snackbar.showMessage( + context.getString(R.string.error_authorization_failed), + ) + }, + ) + } + + Scaffold( + topBar = { + TopNavBar( + title = stringResource(R.string.edit_tunnel), + trailing = { + IconButton(onClick = { + keyboardController?.hide() + viewModel.save(tunnelConf) + }) { + Icon(Icons.Outlined.Save, contentDescription = stringResource(R.string.save)) + } + }, + ) + }, + ) { padding -> + Column( + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 24.dp.scaledHeight()) + .padding(horizontal = 24.dp.scaledWidth()), + ) { + InterfaceSection(uiState, viewModel) + PeersSection(uiState, viewModel) + AddPeerButton(viewModel) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt new file mode 100644 index 00000000..5eac4bde --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/ConfigViewModel.kt @@ -0,0 +1,172 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher +import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +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.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ConfigViewModel @Inject constructor( + private val tunnelRepository: TunnelRepository, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConfigUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initFromTunnel(tunnelConf: TunnelConf?) { + if (tunnelConf == null) return + _uiState.update { + val proxy = ConfigProxy.from(tunnelConf.toAmConfig()) + it.copy( + tunnelName = tunnelConf.name, + configProxy = proxy, + showScripts = proxy.hasScripts(), + showAmneziaValues = proxy.`interface`.junkPacketCount.isNotBlank(), + isAuthenticated = false, + ) + } + } + + fun updateTunnelName(name: String) { + _uiState.update { + it.copy(tunnelName = name) + } + } + + fun updateInterface(newInterface: InterfaceProxy) { + _uiState.update { + it.copy( + configProxy = it.configProxy.copy( + `interface` = newInterface, + ), + ) + } + } + + fun toggleAmneziaValues() { + _uiState.update { + it.copy(showAmneziaValues = !it.showAmneziaValues) + } + } + + fun toggleScripts() { + _uiState.update { + it.copy(showScripts = !it.showScripts) + } + } + + fun toggleAmneziaCompatibility() { + val (show, `interface`) = with(_uiState.value.configProxy) { + if (`interface`.isAmneziaCompatibilityModeSet()) { + Pair(false, `interface`.resetAmneziaProperties()) + } else { + Pair(true, `interface`.toAmneziaCompatibilityConfig()) + } + } + _uiState.update { + it.copy( + showScripts = show, + configProxy = it.configProxy.copy( + `interface` = `interface`, + ), + ) + } + } + + fun addPeer() { + _uiState.update { currentState -> + currentState.copy( + configProxy = currentState.configProxy.copy( + peers = currentState.configProxy.peers + PeerProxy(), + ), + ) + } + } + + fun removePeer(index: Int) { + _uiState.update { currentState -> + currentState.copy( + configProxy = currentState.configProxy.copy( + peers = currentState.configProxy.peers.toMutableList().apply { removeAt(index) }, + ), + ) + } + } + + fun updatePeer(index: Int, peer: PeerProxy) { + _uiState.update { currentState -> + currentState.copy( + configProxy = currentState.configProxy.copy( + peers = currentState.configProxy.peers.toMutableList().apply { set(index, peer) }, + ), + ) + } + } + + fun toggleLanExclusion(index: Int) { + val peer = _uiState.value.configProxy.peers[index] + val updated = if (peer.isLanExcluded()) peer.includeLan() else peer.excludeLan() + updatePeer(index, updated) + } + + fun setMessage(message: StringValue?) { + _uiState.update { + it.copy(message = message) + } + } + + // TODO improve error messaging + fun save(tunnelConf: TunnelConf?) = viewModelScope.launch(ioDispatcher) { + val message = try { + val saveConfig = buildTunnelConfFromState(tunnelConf) + tunnelRepository.save(saveConfig) + StringValue.StringResource(R.string.config_changes_saved) + } catch (e: Exception) { + e.message?.let { message -> + (StringValue.DynamicString(message)) + } ?: StringValue.StringResource(R.string.unknown_error) + } + setMessage(message) + } + + private fun buildTunnelConfFromState(tunnelConf: TunnelConf?): TunnelConf { + val (wg, am) = _uiState.value.configProxy.buildConfigs() + val name = _uiState.value.tunnelName + return tunnelConf?.copyWithCallback( + tunName = name, + amQuick = am.toAwgQuickString(true), + wgQuick = wg.toWgQuickString(true), + ) ?: TunnelConf( + tunName = name, amQuick = am.toAwgQuickString(true), + wgQuick = wg.toWgQuickString(true), + ) + } + + fun onAuthenticated() { + _uiState.update { + it.copy(isAuthenticated = true) + } + } + + fun toggleShowAuthPrompt() { + _uiState.update { + it.copy(showAuthPrompt = !it.showAuthPrompt) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt new file mode 100644 index 00000000..56b18841 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/AddPeerButton.kt @@ -0,0 +1,28 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel + +@Composable +fun AddPeerButton(viewModel: ConfigViewModel) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 140.dp), + ) { + TextButton(onClick = { viewModel.addPeer() }) { + Text(stringResource(R.string.add_peer)) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt new file mode 100644 index 00000000..6398b33a --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceDropdown.kt @@ -0,0 +1,72 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize + +@Composable +fun InterfaceDropdown( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + showScripts: Boolean, + showAmneziaValues: Boolean, + isAmneziaCompatibilitySet: Boolean, + onToggleScripts: () -> Unit, + onToggleAmneziaValues: () -> Unit, + onToggleAmneziaCompatibility: () -> Unit, +) { + Column { + IconButton( + modifier = Modifier.size(iconSize), + onClick = { onExpandedChange(true) }, + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { Text(if (showScripts) stringResource(R.string.hide_scripts) else stringResource(R.string.show_scripts)) }, + onClick = { + onToggleScripts() + onExpandedChange(false) + }, + ) + DropdownMenuItem( + text = { Text(if (showAmneziaValues) stringResource(R.string.hide_amnezia_properties) else stringResource(R.string.show_amnezia_properties)) }, + onClick = { + onToggleAmneziaValues() + onExpandedChange(false) + }, + ) + DropdownMenuItem( + text = { + Text( + if (isAmneziaCompatibilitySet) stringResource(R.string.remove_amnezia_compatibility) else stringResource(R.string.enable_amnezia_compatibility), + ) + }, + onClick = { + onToggleAmneziaCompatibility() + onExpandedChange(false) + }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt new file mode 100644 index 00000000..edddd7a7 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceFields.kt @@ -0,0 +1,220 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy + +@Composable +fun InterfaceFields( + interfaceState: InterfaceProxy, + showAuthPrompt: () -> Unit, + isAuthenticated: Boolean, + showScripts: Boolean, + showAmneziaValues: Boolean, + onInterfaceChange: (InterfaceProxy) -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val clipboardManager = LocalClipboardManager.current + val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + + OutlinedTextField( + value = interfaceState.privateKey, + onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) }, + label = { Text(stringResource(R.string.private_key)) }, + modifier = Modifier.fillMaxWidth().clickable { + if (!isAuthenticated) showAuthPrompt() + }, + visualTransformation = if (isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(enabled = isAuthenticated, onClick = { + val keypair = com.wireguard.crypto.KeyPair() + onInterfaceChange( + interfaceState.copy( + privateKey = keypair.privateKey.toBase64(), + publicKey = keypair.publicKey.toBase64(), + ), + ) + }) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = if (isAuthenticated) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, + ) + } + }, + enabled = isAuthenticated, + singleLine = true, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + OutlinedTextField( + value = interfaceState.publicKey, + onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) }, + label = { Text(stringResource(R.string.public_key)) }, + enabled = false, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { + IconButton(onClick = { + clipboardManager.setText(AnnotatedString(interfaceState.publicKey)) + }) { + Icon(Icons.Rounded.ContentCopy, stringResource(R.string.copy_public_key)) + } + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + ConfigurationTextBox( + value = interfaceState.addresses, + onValueChange = { onInterfaceChange(interfaceState.copy(addresses = it)) }, + label = stringResource(R.string.addresses), + hint = stringResource(R.string.comma_separated_list), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.listenPort, + onValueChange = { onInterfaceChange(interfaceState.copy(listenPort = it)) }, + label = stringResource(R.string.listen_port), + hint = stringResource(R.string.random), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + ConfigurationTextBox( + value = interfaceState.dnsServers, + onValueChange = { onInterfaceChange(interfaceState.copy(dnsServers = it)) }, + label = stringResource(R.string.dns_servers), + hint = stringResource(R.string.comma_separated_list), + modifier = Modifier.weight(3f), + ) + ConfigurationTextBox( + value = interfaceState.mtu, + onValueChange = { onInterfaceChange(interfaceState.copy(mtu = it)) }, + label = stringResource(R.string.mtu), + hint = stringResource(R.string.auto), + modifier = Modifier.weight(2f), + ) + } + if (showScripts) { + ConfigurationTextBox( + value = interfaceState.preUp, + onValueChange = { onInterfaceChange(interfaceState.copy(preUp = it)) }, + label = stringResource(R.string.pre_up), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postUp, + onValueChange = { onInterfaceChange(interfaceState.copy(postUp = it)) }, + label = stringResource(R.string.post_up), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.preDown, + onValueChange = { onInterfaceChange(interfaceState.copy(preDown = it)) }, + label = stringResource(R.string.pre_down), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postDown, + onValueChange = { onInterfaceChange(interfaceState.copy(postDown = it)) }, + label = stringResource(R.string.post_down), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + } + if (showAmneziaValues) { + ConfigurationTextBox( + value = interfaceState.junkPacketCount, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketCount = it)) }, + label = stringResource(R.string.junk_packet_count), + hint = stringResource(R.string.junk_packet_count).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMinSize, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMinSize = it)) }, + label = stringResource(R.string.junk_packet_minimum_size), + hint = stringResource(R.string.junk_packet_minimum_size).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMaxSize, + onValueChange = { onInterfaceChange(interfaceState.copy(junkPacketMaxSize = it)) }, + label = stringResource(R.string.junk_packet_maximum_size), + hint = stringResource(R.string.junk_packet_maximum_size).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.initPacketJunkSize, + onValueChange = { onInterfaceChange(interfaceState.copy(initPacketJunkSize = it)) }, + label = stringResource(R.string.init_packet_junk_size), + hint = stringResource(R.string.init_packet_junk_size).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketJunkSize, + onValueChange = { onInterfaceChange(interfaceState.copy(responsePacketJunkSize = it)) }, + label = stringResource(R.string.response_packet_junk_size), + hint = stringResource(R.string.response_packet_junk_size).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.initPacketMagicHeader, + onValueChange = { onInterfaceChange(interfaceState.copy(initPacketMagicHeader = it)) }, + label = stringResource(R.string.init_packet_magic_header), + hint = stringResource(R.string.init_packet_magic_header).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketMagicHeader, + onValueChange = { onInterfaceChange(interfaceState.copy(responsePacketMagicHeader = it)) }, + label = stringResource(R.string.response_packet_magic_header), + hint = stringResource(R.string.response_packet_magic_header).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.underloadPacketMagicHeader, + onValueChange = { onInterfaceChange(interfaceState.copy(underloadPacketMagicHeader = it)) }, + label = stringResource(R.string.underload_packet_magic_header), + hint = stringResource(R.string.underload_packet_magic_header).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.transportPacketMagicHeader, + onValueChange = { onInterfaceChange(interfaceState.copy(transportPacketMagicHeader = it)) }, + label = stringResource(R.string.transport_packet_magic_header), + hint = stringResource(R.string.transport_packet_magic_header).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt new file mode 100644 index 00000000..eda073e3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/InterfaceSection.kt @@ -0,0 +1,74 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { + var isDropDownExpanded by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + GroupLabel(stringResource(R.string.interface_)) + InterfaceDropdown( + expanded = isDropDownExpanded, + onExpandedChange = { isDropDownExpanded = it }, + showScripts = uiState.showScripts, + showAmneziaValues = uiState.showAmneziaValues, + isAmneziaCompatibilitySet = uiState.configProxy.`interface`.isAmneziaCompatibilityModeSet(), + onToggleScripts = viewModel::toggleScripts, + onToggleAmneziaValues = viewModel::toggleAmneziaValues, + onToggleAmneziaCompatibility = viewModel::toggleAmneziaCompatibility, + ) + } + ConfigurationTextBox( + value = uiState.tunnelName, + onValueChange = viewModel::updateTunnelName, + label = stringResource(R.string.name), + hint = stringResource(R.string.tunnel_name).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + InterfaceFields( + interfaceState = uiState.configProxy.`interface`, + showAuthPrompt = { viewModel.toggleShowAuthPrompt() }, + showScripts = uiState.showScripts, + showAmneziaValues = uiState.showAmneziaValues, + onInterfaceChange = viewModel::updateInterface, + isAuthenticated = uiState.isAuthenticated, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt new file mode 100644 index 00000000..b6ba3cc3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeerFields.kt @@ -0,0 +1,73 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy + +@Composable +fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPrompt: () -> Unit, isAuthenticated: Boolean) { + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + + ConfigurationTextBox( + value = peer.publicKey, + onValueChange = { onPeerChange(peer.copy(publicKey = it)) }, + label = stringResource(R.string.public_key), + hint = stringResource(R.string.base64_key), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + visualTransformation = if (isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(), + value = peer.preSharedKey, + enabled = isAuthenticated, + onValueChange = { onPeerChange(peer.copy(preSharedKey = it)) }, + label = { Text(stringResource(R.string.preshared_key)) }, + placeholder = { Text(stringResource(R.string.optional)) }, + modifier = Modifier.fillMaxWidth().clickable { if (!isAuthenticated) showAuthPrompt() }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = true, + ) + OutlinedTextField( + value = peer.persistentKeepalive, + onValueChange = { onPeerChange(peer.copy(persistentKeepalive = it)) }, + label = { Text(stringResource(R.string.persistent_keepalive)) }, + placeholder = { Text(stringResource(R.string.optional_no_recommend)) }, + trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + ConfigurationTextBox( + value = peer.endpoint, + onValueChange = { onPeerChange(peer.copy(endpoint = it)) }, + label = stringResource(R.string.endpoint), + hint = stringResource(R.string.endpoint).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = peer.allowedIps, + onValueChange = { onPeerChange(peer.copy(allowedIps = it)) }, + label = { Text(stringResource(R.string.allowed_ips)) }, + placeholder = { Text(stringResource(R.string.comma_separated_list)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt new file mode 100644 index 00000000..33b4b81d --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/components/PeersSection.kt @@ -0,0 +1,102 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigViewModel +import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { + uiState.configProxy.peers.forEachIndexed { index, peer -> + var isDropDownExpanded by remember { mutableStateOf(false) } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + GroupLabel(stringResource(R.string.peer)) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + modifier = Modifier.size(iconSize), + onClick = { viewModel.removePeer(index) }, + ) { + Icon(Icons.Rounded.Delete, contentDescription = "Delete") + } + Column { + IconButton( + modifier = Modifier.size(iconSize), + onClick = { isDropDownExpanded = true }, + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = isDropDownExpanded, + onDismissRequest = { isDropDownExpanded = false }, + modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { + Text(if (peer.isLanExcluded()) stringResource(R.string.include_lan) else stringResource(R.string.exclude_lan)) + }, + onClick = { + viewModel.toggleLanExclusion(index) + isDropDownExpanded = false + }, + ) + } + } + } + } + PeerFields( + peer = peer, + onPeerChange = { viewModel.updatePeer(index, it) }, + showAuthPrompt = { viewModel.toggleShowAuthPrompt() }, + isAuthenticated = uiState.isAuthenticated, + ) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt new file mode 100644 index 00000000..ae2beaed --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/config/state/ConfigUiState.kt @@ -0,0 +1,16 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state + +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +import com.zaneschepke.wireguardautotunnel.util.StringValue + +data class ConfigUiState( + val tunnelName: String = "", + val configProxy: ConfigProxy = ConfigProxy(`interface` = InterfaceProxy(), peers = listOf(PeerProxy())), + val showAmneziaValues: Boolean = false, + val showScripts: Boolean = false, + val isAuthenticated: Boolean = true, + val showAuthPrompt: Boolean = false, + val message: StringValue? = null, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt new file mode 100644 index 00000000..10f5fdae --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelScreen.kt @@ -0,0 +1,60 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components.SplitTunnelContent + +@Composable +fun SplitTunnelScreen(viewModel: SplitTunnelViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopNavBar( + title = stringResource(R.string.tunneling_apps), + trailing = { + IconButton(onClick = { viewModel.saveChanges() }) { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.save), + ) + } + }, + ) + }, + ) { padding -> + Crossfade( + targetState = uiState.loading, + animationSpec = tween(200), + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { isLoading -> + if (isLoading) { + SplitTunnelSkeleton() + } else { + SplitTunnelContent( + uiState = uiState, + onSplitOptionChange = viewModel::updateSplitOption, + onAppSelectionToggle = viewModel::toggleAppSelection, + onQueryChange = viewModel::onSearchQuery, + ) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelSkeleton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelSkeleton.kt new file mode 100644 index 00000000..7d0064e3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelSkeleton.kt @@ -0,0 +1,111 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.common.animation.ShimmerEffect +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun SplitTunnelSkeleton() { + val shimmerBrush = ShimmerEffect() + + Column( + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp.scaledHeight()), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp.scaledWidth()) + .height(45.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(3) { + Box( + modifier = Modifier + .weight(1f) + .height(45.dp) + .clip(RoundedCornerShape(8.dp)) + .background(shimmerBrush), + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp.scaledWidth()) + .height(45.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .height(45.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(shimmerBrush), + ) + } + + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + contentPadding = PaddingValues(top = 10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(20) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp.scaledWidth(), vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(iconSize) + .clip(CircleShape) + .background(shimmerBrush), + ) + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .height(20.dp) + .weight(1f) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush), + ) + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(shimmerBrush), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt new file mode 100644 index 00000000..27cf730c --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/SplitTunnelViewModel.kt @@ -0,0 +1,164 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.ui.Route +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.Collator +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class SplitTunnelViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val tunnelRepository: TunnelRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SplitTunnelUiState()) + val uiState: StateFlow = _uiState + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = SplitTunnelUiState(), + ) + + private val tunnelId: Int? = savedStateHandle.get(Route.SplitTunnel.KEY_ID) + private var allTunneledApps: List> = emptyList() + + init { + tunnelId?.let { loadInitialState(it) } + } + + private fun loadInitialState(tunnelId: Int) = viewModelScope.launch { + val tunnel = tunnelRepository.getById(tunnelId) ?: return@launch + val proxyInterface = InterfaceProxy.from(tunnel.toAmConfig().`interface`) + val splitOption = when { + proxyInterface.excludedApplications.isNotEmpty() -> SplitOption.EXCLUDE + proxyInterface.includedApplications.isNotEmpty() -> SplitOption.INCLUDE + else -> SplitOption.ALL + } + + val packages = context.getAllInternetCapablePackages() + + val installedPackages = packages + .map { it.packageName } + .toSet() + + // remove uninstalled apps + proxyInterface.includedApplications.retainAll { it in installedPackages } + proxyInterface.excludedApplications.retainAll { it in installedPackages } + + var configProxy = ConfigProxy.from(tunnel.toAmConfig()) + configProxy = configProxy.copy(`interface` = proxyInterface) + saveProxyConfig(configProxy, tunnel) + + val collator = Collator.getInstance(Locale.getDefault()) + val tunneledApps = packages + .filter { it.applicationInfo != null } + .map { pack -> + val selected = when (splitOption) { + SplitOption.INCLUDE -> proxyInterface.includedApplications.contains(pack.packageName) + SplitOption.ALL -> false + SplitOption.EXCLUDE -> proxyInterface.excludedApplications.contains(pack.packageName) + } + Pair( + TunnelApp( + name = context.packageManager.getApplicationLabel(pack.applicationInfo!!).toString(), + `package` = pack.packageName, + ), + selected, + ) + }.sortedWith(compareBy(collator) { it.first.name }) + + allTunneledApps = tunneledApps + + delay(500) + + _uiState.update { + SplitTunnelUiState( + loading = false, + tunnelConf = tunnel, + tunneledApps = tunneledApps, + splitOption = splitOption, + ) + } + } + + fun onSearchQuery(query: String) { + val filteredApps = if (query.isBlank()) { + allTunneledApps + } else { + allTunneledApps.filter { + it.first.name.contains(query, ignoreCase = true) || + it.first.`package`.contains(query, ignoreCase = true) + } + } + _uiState.update { + it.copy( + searchQuery = query, + tunneledApps = filteredApps, + ) + } + } + + fun updateSplitOption(newOption: SplitOption) { + _uiState.value = _uiState.value.copy(splitOption = newOption) + } + + fun toggleAppSelection(packageName: String) { + val currentState = _uiState.value + val updatedApps = currentState.tunneledApps.map { (app, selected) -> + if (app.`package` == packageName) Pair(app, !selected) else Pair(app, selected) + } + _uiState.value = currentState.copy(tunneledApps = updatedApps) + } + + fun saveChanges() = viewModelScope.launch { + val state = _uiState.value + val tunnel = state.tunnelConf ?: return@launch + val configProxy = ConfigProxy.from(tunnel.toAmConfig()) + val updatedApps = state.tunneledApps + with(configProxy.`interface`) { + includedApplications.clear() + excludedApplications.clear() + when (state.splitOption) { + SplitOption.INCLUDE -> { + includedApplications.addAll(updatedApps.filter { it.second }.map { it.first.`package` }) + } + SplitOption.EXCLUDE -> { + excludedApplications.addAll(updatedApps.filter { it.second }.map { it.first.`package` }) + } + SplitOption.ALL -> Unit + } + } + saveProxyConfig(configProxy, tunnel) + SnackbarController.showMessage(StringValue.StringResource(R.string.config_changes_saved)) + } + + private suspend fun saveProxyConfig(proxy: ConfigProxy, tunnel: TunnelConf) { + val (wg, am) = proxy.buildConfigs() + tunnelRepository.save(tunnel.copyWithCallback(amQuick = am.toAwgQuickString(true), wgQuick = wg.toWgQuickString(true))) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt new file mode 100644 index 00000000..d3a0f706 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListItem.kt @@ -0,0 +1,59 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components + +import android.content.pm.PackageManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) { + val context = LocalContext.current + val icon = remember(appInfo.`package`) { + try { + context.packageManager.getApplicationIcon(appInfo.`package`) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + + SelectionItemButton( + leading = { + Image( + painter = rememberDrawablePainter(icon), + contentDescription = appInfo.name, + modifier = Modifier + .padding(horizontal = 24.dp.scaledWidth()) + .size(iconSize), + ) + }, + buttonText = appInfo.name, + onClick = onToggle, + trailing = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() }, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt new file mode 100644 index 00000000..69183820 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/AppListSection.kt @@ -0,0 +1,77 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun AppListSection(apps: List>, onAppSelectionToggle: (String) -> Unit, onQueryChange: (String) -> Unit, query: String) { + val inputHeight = 45.dp + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + CustomTextField( + textStyle = MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onBackground, + ), + value = query, + onValueChange = onQueryChange, + interactionSource = remember { MutableInteractionSource() }, + label = {}, + leading = { + Icon(Icons.Outlined.Search, stringResource(R.string.search)) + }, + containerColor = MaterialTheme.colorScheme.background, + modifier = Modifier + .fillMaxWidth() + .height(inputHeight) + .padding(horizontal = 24.dp.scaledWidth()), + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(), + ) + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + contentPadding = PaddingValues(top = 10.dp), + ) { + items(apps, key = { it.first.`package` }) { app -> + AppListItem( + appInfo = app.first, + isSelected = app.second, + onToggle = { onAppSelectionToggle(app.first.`package`) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt new file mode 100644 index 00000000..27ce7679 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitOptionsSelector.kt @@ -0,0 +1,79 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MultiChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth +import java.util.* + +@Composable +fun SplitOptionSelector(selectedOption: SplitOption, onOptionChange: (SplitOption) -> Unit) { + val context = LocalContext.current + val inputHeight = 45.dp + + MultiChoiceSegmentedButtonRow( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .fillMaxWidth() + .padding(horizontal = 24.dp.scaledWidth()) + .height(inputHeight), + ) { + SplitOption.entries.forEachIndexed { index, entry -> + val active = selectedOption == entry + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = SplitOption.entries.size, + baseShape = RoundedCornerShape(8.dp), + ), + icon = { + SegmentedButtonDefaults.Icon(active = active, activeContent = { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = stringResource(R.string.select), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize), + ) + }) { + Icon( + imageVector = entry.icon(), + contentDescription = entry.icon().name, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize), + ) + } + }, + colors = SegmentedButtonDefaults.colors().copy( + activeContainerColor = MaterialTheme.colorScheme.surface, + inactiveContainerColor = MaterialTheme.colorScheme.background, + ), + onCheckedChange = { onOptionChange(entry) }, + checked = active, + ) { + Text( + entry.text().asString(context) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt new file mode 100644 index 00000000..132d12da --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/components/SplitTunnelContent.kt @@ -0,0 +1,42 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption +import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight + +@Composable +fun SplitTunnelContent( + uiState: SplitTunnelUiState, + onSplitOptionChange: (SplitOption) -> Unit, + onAppSelectionToggle: (String) -> Unit, + onQueryChange: (String) -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp.scaledHeight()), + ) { + SplitOptionSelector( + selectedOption = uiState.splitOption, + onOptionChange = onSplitOptionChange, + ) + if (uiState.splitOption != SplitOption.ALL) { + AppListSection( + apps = uiState.tunneledApps, + onAppSelectionToggle = onAppSelectionToggle, + onQueryChange = onQueryChange, + uiState.searchQuery, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/enums/SplitOptions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt similarity index 88% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/enums/SplitOptions.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt index 8479d24f..358138d8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/enums/SplitOptions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitOption.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.enums +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -8,7 +8,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.util.StringValue -enum class SplitOptions { +enum class SplitOption { INCLUDE, ALL, EXCLUDE, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt new file mode 100644 index 00000000..9fadb9dd --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/SplitTunnelUiState.kt @@ -0,0 +1,11 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state + +import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf + +data class SplitTunnelUiState( + val loading: Boolean = true, + val tunnelConf: TunnelConf? = null, + val tunneledApps: SplitTunnelApps = emptyList(), + val splitOption: SplitOption = SplitOption.ALL, + val searchQuery: String = "", +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt new file mode 100644 index 00000000..d13872b9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/splittunnel/state/TunnelApp.kt @@ -0,0 +1,8 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state + +data class TunnelApp( + val name: String, + val `package`: String, +) + +typealias SplitTunnelApps = List> diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt new file mode 100644 index 00000000..0d65f706 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigProxy.kt @@ -0,0 +1,36 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import org.amnezia.awg.config.Config + +data class ConfigProxy( + val peers: List, + val `interface`: InterfaceProxy, +) { + + fun hasScripts(): Boolean { + return `interface`.preUp.isNotBlank() || `interface`.preDown.isNotBlank() || + `interface`.postUp.isNotBlank() || `interface`.postDown.isNotBlank() + } + + fun buildConfigs(): Pair { + return Pair( + com.wireguard.config.Config.Builder().apply { + addPeers(peers.map { it.toWgPeer() }) + setInterface(`interface`.toWgInterface()) + }.build(), + Config.Builder().apply { + addPeers(peers.map { it.toAmPeer() }) + setInterface(`interface`.toAmInterface()) + }.build(), + ) + } + + companion object { + fun from(amConfig: Config): ConfigProxy { + return ConfigProxy( + `interface` = InterfaceProxy.from(amConfig.`interface`), + peers = amConfig.peers.map { PeerProxy.from(it) }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelApp.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelApp.kt deleted file mode 100644 index e5d3834d..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelApp.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.state - -import android.graphics.drawable.Drawable - -data class SplitTunnelApp( - val icon: Drawable, - val name: String, - val `package`: String, -) 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 243b6392..219e97b7 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 @@ -42,15 +42,12 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus { } } -fun Peer.isReachable(preferIpv4: Boolean): Boolean { - val host = - if (this.endpoint.isPresent && - this.endpoint.get().resolved.isPresent - ) { - this.endpoint.get().resolved.get().host - } else { - Constants.DEFAULT_PING_IP - } +fun Peer.isReachable(): Boolean { + val host = if (this.endpoint.isPresent) { + this.endpoint.get().host + } else { + Constants.DEFAULT_PING_IP + } Timber.d("Checking reachability of peer: $host") val reachable = InetAddress.getByName(host) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt index 52301b16..b8c44691 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt @@ -1,6 +1,5 @@ package com.zaneschepke.wireguardautotunnel.viewmodel -import android.content.Context import androidx.lifecycle.viewModelScope import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell @@ -16,20 +15,14 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy -import com.zaneschepke.wireguardautotunnel.ui.state.SplitTunnelApp import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages import com.zaneschepke.wireguardautotunnel.util.extensions.withData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -38,7 +31,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext -import org.amnezia.awg.config.Config import timber.log.Timber import xyz.teamgravity.pin_lock_compose.PinManager import javax.inject.Inject @@ -56,18 +48,12 @@ constructor( private val logReader: LogReader, ) : BaseViewModel(appDataRepository) { - private val _popBackStack = MutableSharedFlow() - val popBackStack = _popBackStack.asSharedFlow() - private val _isAppReady = MutableStateFlow(false) val isAppReady = _isAppReady.asStateFlow() private val _configurationChange = MutableStateFlow(false) val configurationChange = _configurationChange.asStateFlow() - private val _splitTunnelApps = MutableStateFlow>(emptyList()) - val splitTunnelApps = _splitTunnelApps.asStateFlow() - val uiState = combine( appDataRepository.settings.flow, @@ -238,7 +224,7 @@ constructor( if (!isKernelEnabled) { requestRoot().onSuccess { if (!isKernelSupported()) { - return@onSuccess SnackbarController.Companion.showMessage( + return@onSuccess SnackbarController.showMessage( StringValue.StringResource(R.string.kernel_not_supported), ) } @@ -262,21 +248,6 @@ constructor( } } - suspend fun getEmitSplitTunnelApps(context: Context) { - withContext(ioDispatcher) { - val apps = context.getAllInternetCapablePackages().filter { it.applicationInfo != null } - .map { pack -> - SplitTunnelApp( - context.packageManager.getApplicationIcon(pack.applicationInfo!!), - context.packageManager.getApplicationLabel(pack.applicationInfo!!) - .toString(), - pack.packageName, - ) - } - _splitTunnelApps.emit(apps) - } - } - private suspend fun requestRoot(): Result { return withContext(ioDispatcher) { runCatching { @@ -287,115 +258,4 @@ constructor( } } } - - fun updateExistingTunnelConfig( - tunnelConfig: TunnelConf, - tunnelName: String? = null, - peers: List? = null, - `interface`: InterfaceProxy? = null, - ) = viewModelScope.launch { - runCatching { - val amConfig = tunnelConfig.toAmConfig() - val wgConfig = tunnelConfig.toWgConfig() - updateTunnelConfig(tunnelConfig, tunnelName, amConfig, wgConfig, peers, `interface`) - _popBackStack.emit(true) - SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.config_changes_saved)) - }.onFailure { - onConfigSaveError(it) - } - } - - fun saveNewTunnel(tunnelName: String, peers: List, `interface`: InterfaceProxy) = viewModelScope.launch { - runCatching { - val config = buildConfigs(peers, `interface`) - appDataRepository.tunnels.save( - TunnelConf( - tunName = tunnelName, - wgQuick = config.first.toWgQuickString(true), - amQuick = config.second.toAwgQuickString(true), - ), - ) - _popBackStack.emit(true) - SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.config_changes_saved)) - }.onFailure { - onConfigSaveError(it) - } - } - - private fun onConfigSaveError(throwable: Throwable) { - Timber.Forest.e(throwable) - SnackbarController.Companion.showMessage( - throwable.message?.let { message -> - (StringValue.DynamicString(message)) - } ?: StringValue.StringResource(R.string.unknown_error), - ) - } - - private suspend fun updateTunnelConfig( - tunnelConf: TunnelConf, - tunnelName: String? = null, - amConfig: Config, - wgConfig: com.wireguard.config.Config, - peers: List? = null, - `interface`: InterfaceProxy? = null, - ) { - val configs = rebuildConfigs(amConfig, wgConfig, peers, `interface`) - appDataRepository.tunnels.save( - tunnelConf.copy( - tunName = tunnelName ?: tunnelConf.tunName, - amQuick = configs.second.toAwgQuickString(true), - wgQuick = configs.first.toWgQuickString(true), - ), - ) - } - - fun cleanUpUninstalledApps(tunnelConfig: TunnelConf, packages: List) = viewModelScope.launch(ioDispatcher) { - runCatching { - val amConfig = tunnelConfig.toAmConfig() - val wgConfig = tunnelConfig.toWgConfig() - val proxy = InterfaceProxy.from(amConfig.`interface`) - if (proxy.includedApplications.isEmpty() && proxy.excludedApplications.isEmpty()) return@launch - if (proxy.includedApplications.retainAll(packages.toSet()) || proxy.excludedApplications.retainAll(packages.toSet())) { - updateTunnelConfig(tunnelConfig, amConfig = amConfig, wgConfig = wgConfig, `interface` = proxy) - Timber.Forest.i("Removed split tunnel package for app that no longer exists on the device") - } - }.onFailure { - Timber.Forest.e(it) - } - } - - private suspend fun rebuildConfigs( - amConfig: Config, - wgConfig: com.wireguard.config.Config, - peers: List? = null, - `interface`: InterfaceProxy? = null, - ): Pair { - return withContext(ioDispatcher) { - Pair( - com.wireguard.config.Config.Builder().apply { - addPeers(peers?.map { it.toWgPeer() } ?: wgConfig.peers) - setInterface(`interface`?.toWgInterface() ?: wgConfig.`interface`) - }.build(), - Config.Builder().apply { - addPeers(peers?.map { it.toAmPeer() } ?: amConfig.peers) - setInterface(`interface`?.toAmInterface() ?: amConfig.`interface`) - }.build(), - ) - } - } - - private suspend fun buildConfigs(peers: List, `interface`: InterfaceProxy): Pair { - return withContext(ioDispatcher) { - Pair( - com.wireguard.config.Config.Builder().apply { - addPeers(peers.map { it.toWgPeer() }) - setInterface(`interface`.toWgInterface()) - }.build(), - Config.Builder().apply { - addPeers(peers.map { it.toAmPeer() }) - setInterface(`interface`.toAmInterface()) - }.build(), - ) - } - } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0d36b53..acaad537 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -215,4 +215,7 @@ Multiple Export as Amnezia Export as WireGuard + Save + Search + Select