fix: bugs in config changes and ping tunnel jobs (#650)

This commit is contained in:
Zane Schepke
2025-03-30 18:31:26 -04:00
committed by GitHub
parent 757669ddbe
commit a873546e9e
37 changed files with 1821 additions and 1557 deletions
@@ -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<Route.Config>()
val config =
appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config, viewModel)
ConfigScreen(config)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
@@ -241,11 +237,8 @@ class MainActivity : AppCompatActivity() {
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
composable<Route.SplitTunnel> { backStack ->
val args = backStack.toRoute<Route.SplitTunnel>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
SplitTunnelScreen(it, viewModel)
}
composable<Route.SplitTunnel> {
SplitTunnelScreen()
}
composable<Route.TunnelAutoTunnel> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
@@ -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
}
}
@@ -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<Map<TunnelConf, TunnelState>>(emptyMap())
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
override val activeTunnels = activeTuns.asStateFlow()
private val tunnelJobs = ConcurrentHashMap<Int, MutableList<Job>>()
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<Job>()
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<String> = activeTuns.value.keys.map { it.tunName }.toSet()
}
@@ -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<String>) {
@@ -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)
}
@@ -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<String>)
suspend fun runningTunnelNames(): Set<String>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
@@ -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<String>) {
@@ -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
@@ -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")
}
}
}
@@ -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(
@@ -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),
)
}
@@ -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))
}
}
}
}
}
}
@@ -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<String>() }
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()
},
)
}
},
)
}
}
}
}
}
}
}
@@ -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)
}
}
}
@@ -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<ConfigUiState> = _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)
}
}
}
@@ -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))
}
}
}
@@ -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)
},
)
}
}
}
@@ -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(),
)
}
}
@@ -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,
)
}
}
}
@@ -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,
)
}
@@ -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,
)
}
}
}
}
@@ -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,
)
@@ -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,
)
}
}
}
}
@@ -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),
)
}
}
}
}
}
@@ -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<SplitTunnelUiState> = _uiState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = SplitTunnelUiState(),
)
private val tunnelId: Int? = savedStateHandle.get<Int>(Route.SplitTunnel.KEY_ID)
private var allTunneledApps: List<Pair<TunnelApp, Boolean>> = 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)))
}
}
@@ -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() },
)
}
},
)
}
@@ -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<Pair<TunnelApp, Boolean>>, 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`) },
)
}
}
}
}
@@ -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,
)
}
}
}
}
@@ -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,
)
}
}
}
@@ -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,
@@ -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 = "",
)
@@ -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<Pair<TunnelApp, Boolean>>
@@ -0,0 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import org.amnezia.awg.config.Config
data class ConfigProxy(
val peers: List<PeerProxy>,
val `interface`: InterfaceProxy,
) {
fun hasScripts(): Boolean {
return `interface`.preUp.isNotBlank() || `interface`.preDown.isNotBlank() ||
`interface`.postUp.isNotBlank() || `interface`.postDown.isNotBlank()
}
fun buildConfigs(): Pair<com.wireguard.config.Config, Config> {
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) },
)
}
}
}
@@ -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,
)
@@ -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)
@@ -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<Boolean>()
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<List<SplitTunnelApp>>(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<Unit> {
return withContext(ioDispatcher) {
runCatching {
@@ -287,115 +258,4 @@ constructor(
}
}
}
fun updateExistingTunnelConfig(
tunnelConfig: TunnelConf,
tunnelName: String? = null,
peers: List<PeerProxy>? = 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<PeerProxy>, `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<PeerProxy>? = 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<String>) = 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<PeerProxy>? = null,
`interface`: InterfaceProxy? = null,
): Pair<com.wireguard.config.Config, Config> {
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<PeerProxy>, `interface`: InterfaceProxy): Pair<com.wireguard.config.Config, Config> {
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(),
)
}
}
}
+3
View File
@@ -215,4 +215,7 @@
<string name="multiple">Multiple</string>
<string name="export_amnezia">Export as Amnezia</string>
<string name="export_wireguard">Export as WireGuard</string>
<string name="save">Save</string>
<string name="search">Search</string>
<string name="select">Select</string>
</resources>