Compare commits

..

13 Commits

Author SHA1 Message Date
dependabot[bot] e990b9644d chore(deps): bump androidGradlePlugin
Bumps `androidGradlePlugin` from 8.8.0-alpha05 to 8.11.0-alpha03.

Updates `com.android.application` from 8.8.0-alpha05 to 8.11.0-alpha03

Updates `com.android.library` from 8.8.0-alpha05 to 8.11.0-alpha03

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.android.library
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 13:27:34 +00:00
Zane Schepke 5f791ffda1 chore: bump ksp 2025-03-30 18:48:47 -04:00
Zane Schepke ec244eeda3 chore: bump deps 2025-03-30 18:46:26 -04:00
Hendrik Volkmer ff2a2cc082 feat: Add option to add config via URL (#623)
Co-authored-by: Zane Schepke <zanecschepke@gmail.com>
2025-03-30 18:37:07 -04:00
Zane Schepke a873546e9e fix: bugs in config changes and ping tunnel jobs (#650) 2025-03-30 18:31:26 -04:00
Zane Schepke 757669ddbe docs: update matrix link 2025-03-23 15:35:18 -04:00
Zane Schepke c71c4e5b29 chore: bump version and notes 2025-03-19 22:35:21 -04:00
Zane Schepke 7f0fea3766 fix: improve wifi monitoring to better handle permission changes 2025-03-19 21:51:54 -04:00
Zane Schepke 53c19762ef fix: attempt to improve tile sync 2025-03-16 23:12:39 -04:00
Zane Schepke c98fa04f73 fix: auto tunnel and tunnel regressions 2025-03-16 20:10:44 -04:00
Zane Schepke aba0f7d4d3 chore: bump deps 2025-03-16 02:05:55 -04:00
Zane Schepke fa517b2124 fix: race conditions (#621) 2025-03-16 02:04:09 -04:00
Zane Schepke d7e2648393 docs: update readme links 2025-03-15 19:22:34 -04:00
58 changed files with 2345 additions and 1715 deletions
+7 -7
View File
@@ -4,7 +4,7 @@ WG Tunnel
<div align="center">
An alternative Android client app for [WireGuard®](https://www.wireguard.com/)
An alternative Android client app for [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<br />
<br />
@@ -23,14 +23,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
[![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
[![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
[![Personal](https://img.shields.io/static/v1?style=for-the-badge&message=Personal&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://github.com/zaneschepke/fdroid)
[![Obtainium](https://img.shields.io/badge/Obtainium-414141?style=for-the-badge&logo=Obtainium&logoColor=white)](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.zaneschepke.wireguardautotunnel%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Fzaneschepke%2Fwgtunnel%22%2C%22author%22%3A%22zaneschepke%22%2C%22name%22%3A%22WG%20Tunnel%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Atrue%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22WG%20Tunnel%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22Zane%20Schepke%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D)
</div>
<div align="center">
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/rbRRNh6H7V)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/wgtunnel)
[<img src="https://img.shields.io/badge/Telegram-26A5E4.svg?style=for-the-badge&logo=Telegram&logoColor=white">](https://t.me/wgtunnel)
[<img src="https://img.shields.io/badge/Matrix-000000.svg?style=for-the-badge&logo=Matrix&logoColor=white">](https://matrix.to/#/#wg-tunnel-space:matrix.org)
</div>
<details open="open">
@@ -49,7 +49,7 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
<div style="text-align: left;">
## About
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard®](https://www.wireguard.com/)
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard](https://www.wireguard.com/)
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its primary feature of auto-tunneling (on-demand tunneling).
</div>
@@ -61,14 +61,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its pr
Thank you to the following:
- All of the users that have helped contribute to the project with ideas, translations, feedback, bug reports, testing, and donations.
- [WireGuard®](https://www.wireguard.com/) - © Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
- [WireGuard](https://www.wireguard.com/) - Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
- [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) - Amnezia Team (https://github.com/amnezia-vpn/amneziawg-android)
## Screenshots
</div>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;">
<div style="display: flex; flex-wrap: wrap; justify-content: left; gap: 10px;">
<img label="Main" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
<img label="Settings" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
<img label="Auto" src="fastlane/metadata/android/en-US/images/phoneScreenshots/auto_screen.png" width="200" />
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
@@ -32,12 +34,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
@@ -47,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
@@ -68,6 +72,7 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.exitProcess
@@ -83,6 +88,11 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var shortcutManager: ShortcutManager
@Inject
lateinit var networkMonitor: NetworkMonitor
private var lastLocationPermissionState: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -115,10 +125,6 @@ class MainActivity : AppCompatActivity() {
}
}
LaunchedEffect(Unit) {
viewModel.getEmitSplitTunnelApps(this@MainActivity)
}
with(appUiState.appSettings) {
LaunchedEffect(isShortcutsEnabled) {
if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts()
@@ -214,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>()
@@ -231,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>()
@@ -256,4 +259,22 @@ class MainActivity : AppCompatActivity() {
}
}
}
override fun onResume() {
super.onResume()
checkPermissionAndNotify()
}
private fun checkPermissionAndNotify() {
val hasLocation = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
if (lastLocationPermissionState != hasLocation) {
Timber.d("Location permission changed to: $hasLocation")
if (hasLocation) {
networkMonitor.sendLocationPermissionsGrantedBroadcast()
}
lastLocationPermissionState = hasLocation
}
}
}
@@ -4,8 +4,6 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -20,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
@@ -36,8 +33,6 @@ class ServiceManager @Inject constructor(
var autoTunnelService = CompletableDeferred<AutoTunnelService>()
var backgroundService = CompletableDeferred<TunnelForegroundService>()
var autoTunnelTile = CompletableDeferred<AutoTunnelControlTile>()
var tunnelControlTile = CompletableDeferred<TunnelControlTile>()
private fun <T : Service> startService(cls: Class<T>, background: Boolean) {
runCatching {
@@ -61,19 +56,16 @@ class ServiceManager @Inject constructor(
runCatching {
autoTunnelService = CompletableDeferred()
startService(AutoTunnelService::class.java, background)
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { autoTunnelService.await() }
?: throw IllegalStateException("AutoTunnelService start timed out")
service.start()
_autoTunnelActive.update { true }
updateAutoTunnelTile()
}.onFailure {
Timber.e(it)
_autoTunnelActive.update { false }
}
}
updateAutoTunnelTile()
}
fun startBackgroundService(tunnelConf: TunnelConf) {
fun startTunnelForegroundService(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (backgroundService.isCompleted) return@launch
runCatching {
@@ -88,7 +80,19 @@ class ServiceManager @Inject constructor(
}
}
fun stopBackgroundService() {
fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.start(tunnelConf)
}.onFailure {
Timber.e(it)
}
}
}
fun stopTunnelForegroundService() {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
@@ -107,34 +111,12 @@ class ServiceManager @Inject constructor(
}
}
suspend fun updateAutoTunnelTile() {
withContext(ioDispatcher) {
runCatching {
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { autoTunnelTile.await() }
?: run {
context.requestAutoTunnelTileServiceUpdate()
return@withContext
}
service.updateTileState()
}.onFailure {
Timber.e(it)
}
}
fun updateAutoTunnelTile() {
context.requestAutoTunnelTileServiceUpdate()
}
suspend fun updateTunnelTile() {
withContext(ioDispatcher) {
runCatching {
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { tunnelControlTile.await() }
?: run {
context.requestTunnelTileServiceStateUpdate()
return@withContext
}
service.updateTileState()
}.onFailure {
Timber.e(it)
}
}
fun updateTunnelTile() {
context.requestTunnelTileServiceStateUpdate()
}
fun stopAutoTunnel() {
@@ -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,21 +68,94 @@ 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() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
serviceManager.backgroundService = CompletableDeferred()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -67,4 +168,9 @@ class TunnelForegroundService : LifecycleService() {
),
)
}
companion object {
const val STATS_DELAY = 1_000L
const val PING_START_DELAY = 30_000L
}
}
@@ -94,9 +94,11 @@ class AutoTunnelService : LifecycleService() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
serviceManager.autoTunnelService.complete(this)
return super.onStartCommand(intent, flags, startId)
start()
return START_STICKY
}
fun start() {
@@ -178,8 +180,8 @@ class AutoTunnelService : LifecycleService() {
combineSettings(),
appDataRepository.get().settings.flow
.distinctUntilChanged { old, new -> old.isKernelEnabled == new.isKernelEnabled } // Only emit when isKernelEnabled changes
.flatMapLatest { settings ->
networkMonitor.getNetworkStatusFlow(true, settings.isKernelEnabled)
.flatMapLatest {
networkMonitor.networkStatusFlow
.flowOn(ioDispatcher)
.map { buildNetworkState(it) }
}
@@ -4,57 +4,61 @@ import android.content.Intent
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AutoTunnelControlTile : TileService() {
class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
override fun onCreate() {
super.onCreate()
serviceManager.autoTunnelTile.complete(this)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onDestroy() {
super.onDestroy()
serviceManager.autoTunnelTile = CompletableDeferred()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
override fun onStartListening() {
super.onStartListening()
serviceManager.autoTunnelTile.complete(this)
applicationScope.launch {
if (appDataRepository.tunnels.getAll().isEmpty()) return@launch setUnavailable()
updateTileState()
Timber.d("Start listening called for auto tunnel tile")
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleScope.launch {
serviceManager.autoTunnelActive.collect {
if (it) setActive() else setInactive()
}
}
}
fun updateTileState() {
serviceManager.autoTunnelActive.value.let {
if (it) setActive() else setInactive()
lifecycleScope.launch {
appDataRepository.tunnels.flow.collect {
if (it.isEmpty()) {
setUnavailable()
} else {
if (qsTile.state == Tile.STATE_ACTIVE) setInactive()
}
}
}
}
override fun onClick() {
super.onClick()
unlockAndRun {
applicationScope.launch {
lifecycleScope.launch {
if (serviceManager.autoTunnelActive.value) {
serviceManager.stopAutoTunnel()
setInactive()
@@ -97,4 +101,7 @@ class AutoTunnelControlTile : TileService() {
qsTile.updateTile()
}
}
override val lifecycle: Lifecycle
get() = lifecycleRegistry
}
@@ -5,58 +5,63 @@ import android.os.Build
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TunnelControlTile : TileService() {
class TunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
private var isCollecting = false
override fun onCreate() {
super.onCreate()
serviceManager.tunnelControlTile.complete(this)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onDestroy() {
super.onDestroy()
serviceManager.tunnelControlTile = CompletableDeferred()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
}
override fun onStartListening() {
super.onStartListening()
Timber.d("Start listening called")
serviceManager.tunnelControlTile.complete(this)
applicationScope.launch {
updateTileState()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Start listening called for tunnel tile")
if (isCollecting) return
isCollecting = true
lifecycleScope.launch {
tunnelManager.activeTunnels.collect {
updateTileState()
}
}
}
fun updateTileState() = applicationScope.launch {
private fun updateTileState() = lifecycleScope.launch {
val tunnels = appDataRepository.tunnels.getAll()
if (tunnels.isEmpty()) return@launch setUnavailable()
with(tunnelManager.activeTunnels.value) {
if (isNotEmpty()) if (size == 1) {
tunnels.firstOrNull { it.id == keys.first() }?.let { return@launch updateTile(it.tunName, true) }
tunnels.firstOrNull { it.id == keys.first().id }?.let { return@launch updateTile(it.tunName, true) }
} else {
return@launch updateTile(getString(R.string.multiple), true)
}
@@ -69,7 +74,7 @@ class TunnelControlTile : TileService() {
override fun onClick() {
super.onClick()
unlockAndRun {
applicationScope.launch {
lifecycleScope.launch {
if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
@@ -132,4 +137,6 @@ class TunnelControlTile : TileService() {
Timber.e(it)
}
}
override val lifecycle: Lifecycle
get() = lifecycleRegistry
}
@@ -1,214 +1,70 @@
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
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider.Companion.CHECK_INTERVAL
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.BackendError
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.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.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
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
open class BaseTunnel(
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 {
internal val tunnels = MutableStateFlow<List<TunnelConf>>(emptyList())
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
override val activeTunnels = activeTuns.asStateFlow()
private val _tunnelStates = MutableStateFlow<Map<Int, TunnelState>>(emptyMap())
private val isBounce = AtomicBoolean(false)
private val tunnelJobs = ConcurrentHashMap<Int, Job>()
private val mutex = Mutex()
private val isNetworkAvailable = AtomicBoolean(false)
abstract suspend fun startBackend(tunnel: TunnelConf)
init {
applicationScope.launch(ioDispatcher) {
launch { startNetworkJob() }
launch { monitorTunnelConfigChanges() }
tunnels.collect { tuns ->
val previousTunIds = tunnelJobs.keys.toSet()
val currentTunIds = tuns.map { it.id }.toSet()
val newTuns = tuns.filter { it.id !in previousTunIds }
val removedTunIds = previousTunIds - currentTunIds
abstract fun stopBackend(tunnel: TunnelConf)
newTuns.forEach { tun ->
Timber.d("Starting tunnel jobs for tun ${tun.name} (ID: ${tun.id})")
tunnelJobs[tun.id] = startTunnelJobs(tun)
}
private fun findActiveTunnel(id: Int): TunnelConf? = activeTuns.value.keys.find { it.id == id }
removedTunIds.forEach { tunId ->
tunnelJobs[tunId]?.cancelWithMessage("Canceling tunnel jobs for tunnel ID: $tunId")
tunnelJobs.remove(tunId)
_tunnelStates.update { it - tunId }
serviceManager.updateTunnelTile()
}
}
private fun isTunnelActive(id: Int) = (activeTuns.value.any { it.key.id == id })
private fun handleBackendThrowable(throwable: Throwable) {
val backendError = when (throwable) {
is BackendException -> throwable.toBackendError()
is org.amnezia.awg.backend.BackendException -> throwable.toBackendError()
else -> BackendError.Unknown
}
}
private fun startTunnelJobs(tunnel: TunnelConf) = applicationScope.launch(ioDispatcher) {
launch { startTunnelStatisticsJob(tunnel) }
if (tunnel.isPingEnabled) launch { startPingJob(tunnel) }
}
private fun updateTunnelState(tunnelId: Int, newState: TunnelStatus) {
Timber.d("Updating tunnel state for ID $tunnelId to $newState")
_tunnelStates.update { current ->
val currentState = current[tunnelId]
val updatedState = currentState?.copy(state = newState) ?: TunnelState(state = newState)
val newMap = current + (tunnelId to updatedState)
Timber.d("New tunnel states: $newMap")
newMap
}
}
internal fun beforeStartTunnel(tunnelConf: TunnelConf) {
tunnelConf.setStateChangeCallback { state ->
Timber.d("New tunnel state $state")
when (state) {
is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState())
}
applicationScope.launch(ioDispatcher) {
serviceManager.updateTunnelTile()
}
}
}
override fun startTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
serviceManager.startBackgroundService(tunnelConf)
appDataRepository.tunnels.save(tunnelConf.copy(isActive = true))
addToActiveTunnels(tunnelConf)
}
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
// Default empty implementation; subclasses override
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf) {
stopTunnel(tunnelConf)
delay(1000)
startTunnel(tunnelConf)
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
// Default empty implementation
}
override suspend fun runningTunnelNames(): Set<String> {
return emptySet()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
throw NotImplementedError("Get statistics not implemented in base class")
}
override val activeTunnels: StateFlow<Map<Int, TunnelState>>
get() = _tunnelStates.asStateFlow()
internal suspend fun onTunnelStop(tunnelConf: TunnelConf) {
appDataRepository.tunnels.save(tunnelConf.copy(isActive = false))
removeFromActiveTunnels(tunnelConf)
if (tunnels.value.isEmpty()) serviceManager.stopBackgroundService()
}
internal fun stopAllTunnels() {
tunnels.value.forEach {
stopTunnel(it)
}
}
private fun addToActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
add(conf)
}
}
}
private fun removeFromActiveTunnels(conf: TunnelConf) {
tunnels.update {
it.toMutableList().apply {
remove(conf)
}
}
}
private suspend fun startNetworkJob() = coroutineScope {
networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false)
.flowOn(ioDispatcher).collect {
isNetworkAvailable.set(it !is NetworkStatus.Disconnected)
}
}
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
runCatching {
if (isNetworkAvailable.get() && tunnel.isActive) {
val pingSuccess = tunnel.isTunnelPingable(ioDispatcher)
handlePingResult(tunnel, pingSuccess)
}
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL)
}
}
}
private suspend fun handlePingResult(tunnel: TunnelConf, pingSuccess: Boolean) {
if (!pingSuccess) {
if (isNetworkAvailable.get()) {
Timber.i("Ping result: target was not reachable, bouncing the tunnel")
bounceTunnel(tunnel)
delay(tunnel.pingCooldown ?: Constants.PING_COOLDOWN)
} else {
Timber.i("Ping result: target was not reachable, but no network available")
}
} else {
Timber.i("Ping result: all ping targets were reached successfully")
}
}
internal fun handleBackendThrowable(backendError: BackendError) {
val message = when (backendError) {
BackendError.Config -> StringValue.StringResource(R.string.start_failed_config)
BackendError.DNS -> StringValue.StringResource(R.string.dns_error)
BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized)
BackendError.Unknown -> StringValue.StringResource(R.string.unknown_error)
}
if (WireGuardAutoTunnel.isForeground()) {
SnackbarController.showMessage(message)
@@ -224,33 +80,134 @@ open class BaseTunnel(
}
}
private suspend fun monitorTunnelConfigChanges() = coroutineScope {
appDataRepository.tunnels.flow.collect { storageTuns ->
storageTuns.forEach { storageTun ->
val currentTun = tunnels.value.firstOrNull { it.id == storageTun.id }
if (currentTun != null) {
if (!currentTun.isQuickConfigMatching(storageTun)) {
Timber.d("Tunnel config changed for ID $storageTun, bouncing tunnel")
bounceTunnel(storageTun)
private fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
activeTuns.update { current ->
val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
val newState = state ?: existingState.state
if (newState == TunnelStatus.DOWN) {
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
current - originalConf
} else if (existingState.state == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
current
} else {
val updated = existingState.copy(
state = newState,
statistics = stats ?: existingState.statistics,
)
current + (originalConf to updated)
}
}
}
}
}
private suspend fun startTunnelStatisticsJob(tunnel: TunnelConf) = coroutineScope {
while (this.isActive) {
runCatching {
val stats = getStatistics(tunnel)
_tunnelStates.update { currentStates ->
val updatedState = currentStates[tunnel.id]?.copy(statistics = stats)
?: TunnelState(statistics = stats)
currentStates + (tunnel.id to updatedState)
}
delay(CHECK_INTERVAL)
}.onFailure { exception ->
Timber.e(exception, "Failed to update tunnel statistics for ${tunnel.tunName}")
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())
}
serviceManager.updateTunnelTile()
}
tunnelConf.setTunnelStatsCallback {
val stats = getStatistics(tunnelConf)
updateTunnelState(tunnelConf, null, stats)
}
tunnelConf.setBounceTunnelCallback(::bounceTunnel)
}
override fun startTunnel(tunnelConf: TunnelConf) {
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?) {
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 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 suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet()
}
@@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
fun Map<TunnelConf, TunnelState>.allDown(): Boolean {
return this.all { it.value.state.isDown() }
}
fun Map<TunnelConf, TunnelState>.hasActive(): Boolean {
return this.any { it.value.state.isUp() }
}
fun Map<TunnelConf, TunnelState>.getValueById(id: Int): TunnelState? {
val key = this.keys.find { it.id == id }
return key?.let { this@getValueById[it] }
}
fun Map<TunnelConf, TunnelState>.getKeyById(id: Int): TunnelConf? {
return this.keys.find { it.id == id }
}
fun Map<TunnelConf, TunnelState>.isUp(tunnelConf: TunnelConf): Boolean {
return this.getValueById(tunnelConf.id)?.state?.isUp() ?: false
}
@@ -1,9 +1,7 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
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
@@ -13,10 +11,8 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
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.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -27,46 +23,24 @@ class KernelTunnel @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.d("Starting tunnel ${tunnelConf.id} kernel")
applicationScope.launch(ioDispatcher) {
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
runCatching {
Timber.d("Setting backend state UP")
super.beforeStartTunnel(tunnelConf)
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig())
Timber.d("Calling super.startTunnel")
super.startTunnel(tunnelConf)
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} kernel")
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
} else {
Timber.e(it)
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
WireGuardStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e)
null
}
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return WireGuardStatistics(backend.getStatistics(tunnelConf))
override suspend fun startBackend(tunnel: TunnelConf) {
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) {
runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let {
backend.setState(it, Tunnel.State.DOWN, it.toWgConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
}
}
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)
}
@@ -76,7 +76,7 @@ class TunnelManager @Inject constructor(
return tunnelProviderFlow.value.runningTunnelNames()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf)
}
@@ -84,7 +84,7 @@ class TunnelManager @Inject constructor(
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels.value.any { tun.id == it.key } }
val tunsToStart = previouslyActiveTuns.filterNot { tun -> activeTunnels.value.any { tun.id == it.key.id } }
if (settings.isKernelEnabled) {
return@launch tunsToStart.forEach {
startTunnel(it)
@@ -7,15 +7,11 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider {
companion object {
const val CHECK_INTERVAL = 1000L
}
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
val activeTunnels: StateFlow<Map<Int, TunnelState>>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>>
}
@@ -1,7 +1,5 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
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
@@ -12,10 +10,8 @@ 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.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
@@ -28,49 +24,20 @@ 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) {
applicationScope.launch(ioDispatcher) {
Timber.d("Starting tunnel ${tunnelConf.id} userspace")
if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running")
if (tunnels.value.isNotEmpty()) {
Timber.d("Stopping all tunnels")
stopAllTunnels()
}
runCatching {
Timber.d("Setting backend state UP")
super.beforeStartTunnel(tunnelConf)
backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig())
Timber.d("Calling super.startTunnel")
super.startTunnel(tunnelConf)
}.onFailure {
Timber.e(it, "Failed to start tunnel ${tunnelConf.id} userspace")
onTunnelStop(tunnelConf)
if (it is BackendException) {
handleBackendThrowable(it.toBackendError())
} else {
Timber.e(it)
}
}
}
override suspend fun startBackend(tunnel: TunnelConf) {
stopActiveTunnels()
backend.setState(tunnel, Tunnel.State.UP, tunnel.toAmConfig())
}
override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) {
runCatching {
tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let {
backend.setState(it, Tunnel.State.DOWN, it.toAmConfig())
onTunnelStop(it)
} ?: stopAllTunnels()
}.onFailure {
Timber.e(it)
}
}
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>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
}
@@ -78,7 +45,12 @@ class UserspaceTunnel @Inject constructor(
return backend.runningTunnelNames
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics {
return AmneziaStatistics(backend.getStatistics(tunnelConf))
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
AmneziaStatistics(backend.getStatistics(tunnelConf))
} catch (e: Exception) {
Timber.e(e, "Failed to get stats for ${tunnelConf.tunName}")
null
}
}
}
@@ -13,6 +13,7 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -20,6 +21,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.backend.RootTunnelActionHandler
@@ -65,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
@@ -81,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
@@ -101,8 +101,8 @@ class TunnelModule {
@Provides
@Singleton
fun provideNetworkMonitor(@ApplicationContext context: Context): NetworkMonitor {
return AndroidNetworkMonitor(context)
fun provideNetworkMonitor(@ApplicationContext context: Context, settingsRepository: AppSettingRepository): NetworkMonitor {
return AndroidNetworkMonitor(context) { runBlocking { settingsRepository.get().isWifiNameByShellEnabled } }
}
@Singleton
@@ -1,15 +1,15 @@
package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
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.withContext
import kotlinx.serialization.Transient
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import java.io.InputStream
import java.net.InetAddress
import java.nio.charset.StandardCharsets
import kotlin.coroutines.CoroutineContext
data class TunnelConf(
@@ -29,12 +29,59 @@ data class TunnelConf(
val isIpv4Preferred: Boolean = true,
@Transient
private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, com.wireguard.android.backend.Tunnel {
@Transient
private var tunnelStatsCallback: (() -> Unit)? = null,
@Transient
private var bounceTunnelCallback: ((TunnelConf) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
}
fun setTunnelStatsCallback(callback: (() -> Unit)) {
tunnelStatsCallback = callback
}
fun setBounceTunnelCallback(callback: (TunnelConf) -> Unit) {
bounceTunnelCallback = callback
}
fun copyWithCallback(
id: Int = this.id,
tunName: String = this.tunName,
wgQuick: String = this.wgQuick,
tunnelNetworks: List<String> = this.tunnelNetworks,
isMobileDataTunnel: Boolean = this.isMobileDataTunnel,
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
amQuick: String = this.amQuick,
isActive: Boolean = this.isActive,
isPingEnabled: Boolean = this.isPingEnabled,
pingInterval: Long? = this.pingInterval,
pingCooldown: Long? = this.pingCooldown,
pingIp: String? = this.pingIp,
isEthernetTunnel: Boolean = this.isEthernetTunnel,
isIpv4Preferred: Boolean = this.isIpv4Preferred,
): TunnelConf {
return TunnelConf(
id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel,
amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp,
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 })
}
@@ -43,15 +90,11 @@ data class TunnelConf(
return configFromWgQuick(wgQuick)
}
override fun getName(): String {
return tunName
}
override fun getName(): String = tunName
override fun isIpv4ResolutionPreferred(): Boolean {
return isIpv4Preferred
}
override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred
override fun onStateChange(newState: com.wireguard.android.backend.Tunnel.State) {
override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) {
stateChangeCallback?.invoke(newState)
}
@@ -59,16 +102,8 @@ data class TunnelConf(
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 {
@@ -76,26 +111,29 @@ 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")
}
}
Timber.i("Pinging all peers")
config.peers.map { peer ->
peer.isReachable(isIpv4Preferred)
}.all { true }
peer.isReachable()
}.all { true }.also {
Timber.i("Ping of all peers reachable: $it")
}
}
}
companion object {
fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
Config.parse(it)
}
}
fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config {
val inputStream: InputStream = amQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use {
return inputStream.bufferedReader(StandardCharsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it)
}
}
@@ -4,4 +4,5 @@ sealed class BackendError() {
data object DNS : BackendError()
data object Unauthorized : BackendError()
data object Config : BackendError()
data object Unknown : BackendError()
}
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.domain.enums
enum class TunnelStatus {
UP,
DOWN,
STARTING,
STOPPING,
;
fun isDown(): Boolean {
@@ -1,5 +1,8 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -7,7 +10,7 @@ import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val networkState: NetworkState = NetworkState(),
val settings: AppSettings = AppSettings(),
val tunnels: List<TunnelConf> = emptyList(),
@@ -20,12 +23,12 @@ data class AutoTunnelState(
private fun isMobileTunnelDataChangeNeeded(): Boolean {
val preferredTunnel = preferredMobileDataTunnel()
return preferredTunnel != null &&
activeTunnels.isNotEmpty() && !activeTunnels.any { it.key == preferredTunnel.id }
activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
}
private fun isEthernetTunnelChangeNeeded(): Boolean {
val preferredTunnel = preferredEthernetTunnel()
return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.any { it.key == preferredTunnel.id }
return preferredTunnel != null && activeTunnels.isNotEmpty() && !activeTunnels.isUp(preferredTunnel)
}
private fun preferredMobileDataTunnel(): TunnelConf? {
@@ -45,11 +48,11 @@ data class AutoTunnelState(
}
private fun startOnEthernet(): Boolean {
return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.isEmpty()
return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && activeTunnels.allDown()
}
private fun stopOnEthernet(): Boolean {
return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.isNotEmpty()
return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && activeTunnels.hasActive()
}
// TODO test removed kill switch state check
@@ -67,11 +70,11 @@ data class AutoTunnelState(
}
private fun stopOnMobileData(): Boolean {
return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.isNotEmpty()
return isMobileDataActive() && !settings.isTunnelOnMobileDataEnabled && activeTunnels.hasActive()
}
private fun startOnMobileData(): Boolean {
return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.isEmpty()
return isMobileDataActive() && settings.isTunnelOnMobileDataEnabled && activeTunnels.allDown()
}
private fun changeOnMobileData(): Boolean {
@@ -83,24 +86,24 @@ data class AutoTunnelState(
}
private fun stopOnWifi(): Boolean {
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty()
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.hasActive()
}
private fun stopOnTrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && isCurrentSSIDTrusted()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && isCurrentSSIDTrusted()
}
private fun startOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isEmpty() && !isCurrentSSIDTrusted()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.allDown() && !isCurrentSSIDTrusted()
}
private fun changeOnUntrustedWifi(): Boolean {
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.isNotEmpty() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred()
return isWifiActive() && settings.isTunnelOnWifiEnabled && activeTunnels.hasActive() && !isCurrentSSIDTrusted() && !isWifiTunnelPreferred()
}
private fun isWifiTunnelPreferred(): Boolean {
val preferred = preferredWifiTunnel()
return activeTunnels.any { it.key == preferred?.id }
return preferred?.let { activeTunnels.isUp(it) } ?: true
}
fun asAutoTunnelEvent(): AutoTunnelEvent {
@@ -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))
}
}
}
}
}
}
@@ -38,6 +38,7 @@ 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.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
@@ -55,6 +56,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingSta
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@@ -75,6 +77,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
var isFabVisible by rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConf?>(null) }
var showUrlImportDialog by remember { mutableStateOf(false) }
val isRunningOnTv = remember { context.isRunningOnTv() }
val activeTunnels by viewModel.tunnelManager.activeTunnels.collectAsStateWithLifecycle(emptyMap())
@@ -197,7 +200,19 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
)
},
onUrlClick = { showUrlImportDialog = true }
)
if (showUrlImportDialog) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.onUrlImport(url)
showUrlImportDialog = false
}
)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
@@ -227,7 +242,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
key = { tunnel -> tunnel.id },
) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded
val tunnelState = activeTunnels.getOrDefault(tunnel.id, TunnelState())
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
tunnelState.state.isUp(),
expanded,
@@ -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()
},
)
}
},
)
}
}
}
}
}
}
}
@@ -32,6 +32,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
@@ -195,7 +196,7 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
enabled = !appUiState.activeTunnels.containsKey(tunnelConf.id),
enabled = !appUiState.activeTunnels.isUp(tunnelConf),
onClick = { onPingToggle() },
)
},
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.ContentPasteGo
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ fun TunnelImportSheet(
onQrClick: () -> Unit,
onManualImportClick: () -> Unit,
onClipboardClick: () -> Unit,
onUrlClick: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
@@ -110,6 +112,27 @@ fun TunnelImportSheet(
}
}
HorizontalDivider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
onDismiss()
onUrlClick()
}
.padding(10.dp),
) {
Icon(
Icons.Filled.Link,
contentDescription = stringResource(id = R.string.add_from_url),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.add_from_url),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier
@@ -0,0 +1,58 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun UrlImportDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_from_url)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.enter_config_url)) },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(url) },
enabled = url.isNotBlank()
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@@ -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>>
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
data class AppUiState(
val appSettings: AppSettings = AppSettings(),
val tunnels: List<TunnelConf> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val generalState: GeneralState = GeneralState(),
val autoTunnelActive: Boolean = false,
)
@@ -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,
@@ -147,6 +133,8 @@ constructor(
appDataRepository.appState.setLocalLogsEnabled(toggledOn)
if (!toggledOn) {
logReader.stop()
} else {
logReader.start()
}
}
}
@@ -236,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),
)
}
@@ -260,140 +248,14 @@ 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)
}
}
suspend fun requestRoot(): Result<Unit> {
private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) {
runCatching {
rootShell.get().start()
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.root_accepted))
SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_root_denied))
SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
}
}
}
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.Companion.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(),
)
}
}
}
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
import timber.log.Timber
import java.io.InputStream
import java.net.URL
import java.util.zip.ZipInputStream
import javax.inject.Inject
@@ -241,4 +242,28 @@ constructor(
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_format))
}
}
fun onUrlImport(urlString: String) = viewModelScope.launch(ioDispatcher) {
runCatching {
val url = URL(urlString)
val fileName = urlString.substringAfterLast("/")
if (!fileName.endsWith(Constants.CONF_FILE_EXTENSION)) {
throw InvalidFileExtensionException
}
url.openStream().use { stream ->
saveTunnelConfigFromStream(stream, fileName)
}
}.onFailure {
Timber.Forest.e(it)
when (it) {
is InvalidFileExtensionException -> {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_extension))
}
else -> {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_download_failed))
}
}
}
}
}
+7
View File
@@ -215,4 +215,11 @@
<string name="multiple">Multiple</string>
<string name="export_amnezia">Export as Amnezia</string>
<string name="export_wireguard">Export as WireGuard</string>
<string name="add_from_url">Add from URL</string>
<string name="enter_config_url">Enter config URL</string>
<string name="error_download_failed">Failed to download config</string>
<string name="error_invalid_url">Invalid URL</string>
<string name="save">Save</string>
<string name="search">Search</string>
<string name="select">Select</string>
</resources>
+2 -2
View File
@@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.7.1"
const val VERSION_NAME = "3.7.2"
const val JVM_TARGET = "17"
const val VERSION_CODE = 37100
const val VERSION_CODE = 37200
const val TARGET_SDK = 35
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -0,0 +1,5 @@
What's new:
- Auto tunnel regression fix
- Tile sync improvements
- Optimize wifi name querying
- Improve network monitoring permission checks
+7 -7
View File
@@ -6,24 +6,24 @@ androidx-junit = "1.2.1"
appcompat = "1.7.0"
biometricKtx = "1.2.0-alpha05"
coreKtx = "1.15.0"
datastorePreferences = "1.1.3"
datastorePreferences = "1.1.4"
desugar_jdk_libs = "2.1.5"
espressoCore = "3.6.1"
hiltAndroid = "2.55"
hiltAndroid = "2.56"
hiltCompiler = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.8.0"
lifecycle-runtime-compose = "2.8.7"
material3 = "1.3.1"
navigationCompose = "2.8.8"
navigationCompose = "2.8.9"
pinLockCompose = "1.0.4"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.2.7"
androidGradlePlugin = "8.8.0-alpha05"
kotlin = "2.1.10"
ksp = "2.1.10-1.0.31"
composeBom = "2025.02.00"
androidGradlePlugin = "8.11.0-alpha03"
kotlin = "2.1.20"
ksp = "2.1.20-1.0.32"
composeBom = "2025.03.01"
compose = "1.7.8"
workRuntimeKtxVersion = "2.10.0"
zxingAndroidEmbedded = "4.3.0"
+8 -6
View File
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
tools:targetApi="29" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>
@@ -1,74 +1,139 @@
package com.zaneschepke.networkmonitor
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.os.Build
import com.wireguard.android.util.RootShell
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import timber.log.Timber
class AndroidNetworkMonitor(
context: Context,
private val useRootShellCallback: suspend () -> Boolean,
) : NetworkMonitor {
companion object {
const val LOCATION_GRANTED = "LOCATION_PERMISSIONS_GRANTED"
const val LOCATION_SERVICES_FILTER = "android.location.PROVIDERS_CHANGED"
}
private val appContext = context.applicationContext
private val packageName = appContext.packageName
private val connectivityManager = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager?
private val locationManager = appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val rootShell = RootShell(context)
private var includeWifiSsid = false
private var useRootShell = false
@get:Synchronized @set:Synchronized
var currentSsid: String? = null
@get:Synchronized @set:Synchronized
var wifiConnected = false
data class WifiState(val connected: Boolean = false, val ssid: String? = null)
data class TransportState(val connected: Boolean = false)
private val wifiFlow: Flow<WifiState> = callbackFlow {
var currentSsid: String? = null
@Suppress("DEPRECATION")
fun getWifiSsid(): String? {
if (!includeWifiSsid || wifiManager == null) return null
return if (useRootShell) {
return if (runBlocking { useRootShellCallback() }) {
rootShell.getCurrentWifiName()
} else {
wifiManager.connectionInfo?.ssid?.trim('"')?.takeIf {
it != "<unknown>" && it.isNotEmpty()
if (wifiManager == null) return null
try {
wifiManager.connectionInfo?.ssid?.trim('"')?.takeIf { it.isNotEmpty() }
} catch (e: Exception) {
Timber.e(e)
null
}
}
}
fun handleUnknownWifi() {
val newSsid = getWifiSsid()
// Only update if new SSID is valid; preserve existing valid SSID otherwise
if (newSsid != null && newSsid != WifiManager.UNKNOWN_SSID) {
currentSsid = newSsid
trySend(WifiState(connected = wifiConnected, ssid = currentSsid))
} else if (currentSsid == null || currentSsid == WifiManager.UNKNOWN_SSID) {
currentSsid = newSsid
trySend(WifiState(connected = wifiConnected, ssid = currentSsid))
}
Timber.d("handleUnknownWifi: currentSsid=$currentSsid")
}
val locationPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Timber.d("locationPermissionReceiver received intent with action: ${intent.action}")
if (intent.action == "$packageName.$LOCATION_GRANTED") {
Timber.d("Received update: Precise and all-the-time location permissions are enabled")
handleUnknownWifi()
}
}
}
val locationServicesReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LOCATION_SERVICES_FILTER) {
val isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
val isLocationServicesEnabled = isGpsEnabled || isNetworkEnabled
Timber.d("Location Services state changed. Enabled: $isLocationServicesEnabled, GPS: $isGpsEnabled, Network: $isNetworkEnabled")
if (isLocationServicesEnabled) handleUnknownWifi()
}
}
}
// Use RECEIVER_NOT_EXPORTED for Android 14+ compatibility
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Context.RECEIVER_EXPORTED
} else {
0
}
appContext.registerReceiver(
locationPermissionReceiver,
IntentFilter("$packageName.$LOCATION_GRANTED"),
flags,
)
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
flags,
)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.d("Wi-Fi onAvailable: network=$network")
val capabilities = connectivityManager.getNetworkCapabilities(network)
val connected = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
if (connected) {
val ssid = getWifiSsid()
currentSsid = ssid
trySend(WifiState(connected = true, ssid = ssid))
}
currentSsid = getWifiSsid()
wifiConnected = true
trySend(WifiState(connected = true, ssid = currentSsid))
}
override fun onLost(network: Network) {
Timber.d("Wi-Fi onLost: network=$network")
currentSsid = null
wifiConnected = false
trySend(WifiState(connected = false, ssid = null))
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
val connected = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
val ssid = if (connected) getWifiSsid() else null
if (ssid != currentSsid) {
currentSsid = ssid
trySend(WifiState(connected = connected, ssid = ssid))
}
Timber.d("Wi-Fi onCapabilitiesChanged: network=$network, networkCapabilities=$networkCapabilities")
}
}
@@ -80,7 +145,11 @@ class AndroidNetworkMonitor(
connectivityManager.registerNetworkCallback(request, callback)
trySend(WifiState())
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
appContext.unregisterReceiver(locationPermissionReceiver)
appContext.unregisterReceiver(locationServicesReceiver)
}
}
private val cellularFlow: Flow<TransportState> = callbackFlow {
@@ -104,7 +173,9 @@ class AndroidNetworkMonitor(
connectivityManager.registerNetworkCallback(request, callback)
trySend(TransportState())
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
private val ethernetFlow: Flow<TransportState> = callbackFlow {
@@ -131,21 +202,24 @@ class AndroidNetworkMonitor(
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}
override fun getNetworkStatusFlow(includeWifiSsid: Boolean, useRootShell: Boolean): Flow<NetworkStatus> {
this.includeWifiSsid = includeWifiSsid
this.useRootShell = useRootShell
return combine(wifiFlow, cellularFlow, ethernetFlow) { wifi, cellular, ethernet ->
val hasAnyConnection = wifi.connected || cellular.connected || ethernet.connected
if (hasAnyConnection) {
NetworkStatus.Connected(
wifiSsid = wifi.ssid,
wifiConnected = wifi.connected,
cellularConnected = cellular.connected,
ethernetConnected = ethernet.connected,
)
} else {
NetworkStatus.Disconnected
}.also { Timber.d("NetworkStatus: $it") }
}.distinctUntilChanged()
override val networkStatusFlow = combine(wifiFlow, cellularFlow, ethernetFlow) { wifi, cellular, ethernet ->
val hasAnyConnection = wifi.connected || cellular.connected || ethernet.connected
if (hasAnyConnection) {
NetworkStatus.Connected(
wifiSsid = wifi.ssid,
wifiConnected = wifi.connected,
cellularConnected = cellular.connected,
ethernetConnected = ethernet.connected,
)
} else {
NetworkStatus.Disconnected
}.also { Timber.d("NetworkStatus: $it") }
}.distinctUntilChanged()
override fun sendLocationPermissionsGrantedBroadcast() {
val action = "$packageName.$LOCATION_GRANTED"
val intent = Intent(action)
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
}
}
@@ -3,5 +3,6 @@ package com.zaneschepke.networkmonitor
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
fun getNetworkStatusFlow(includeWifiSsid: Boolean, useRootShell: Boolean): Flow<NetworkStatus>
val networkStatusFlow: Flow<NetworkStatus>
fun sendLocationPermissionsGrantedBroadcast()
}