Compare commits

..

22 Commits

Author SHA1 Message Date
dependabot[bot] 570d70e441 chore(deps): bump hiltAndroid from 2.56 to 2.56.1
Bumps `hiltAndroid` from 2.56 to 2.56.1.

Updates `com.google.dagger:hilt-android` from 2.56 to 2.56.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.56...dagger-2.56.1)

Updates `com.google.dagger:hilt-android-compiler` from 2.56 to 2.56.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.56...dagger-2.56.1)

Updates `com.google.dagger.hilt.android` from 2.56 to 2.56.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.56...dagger-2.56.1)

---
updated-dependencies:
- dependency-name: com.google.dagger:hilt-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.dagger.hilt.android
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 13:27:24 +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
Zane Schepke 53ff3bb1e5 chore: bump version with notes 2025-03-15 13:10:53 -04:00
Zane Schepke 97ede3d5b4 fix: set prefer ipv4 as default to mimic old wg behavior 2025-03-15 12:44:56 -04:00
Zane Schepke dcd15f7bd8 fix: adding new peer bug
Closes #612
2025-03-15 11:30:10 -04:00
Zane Schepke 6031d85edd fix: re-enable shortcuts
Closes #616
2025-03-15 00:16:02 -04:00
Weblate (bot) a71f8f86b1 feat: Translations update from Hosted Weblate (#556)
Co-authored-by: Zane Schepke <zanecschepke@gmail.com>
Co-authored-by: solokot <solokot@gmail.com>
Co-authored-by: lateweb <weblate@techkoala.net>
Co-authored-by: Matthaiks <kitynska@gmail.com>
Co-authored-by: Kachelkaiser <kachelkaiser@outlook.com>
Co-authored-by: CyanWolf <hydemr@pm.me>
Co-authored-by: Henrik Sozzi <henrik_sozzi@hotmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: x86_64-pc-linux-gnu <x86_64-pc-linux-gnu@proton.me>
Co-authored-by: mak7im01 <mak7im02@gmail.com>
Co-authored-by: heykanspor <meingithub@heykan.de>
2025-03-14 22:51:26 -04:00
Zane Schepke 007c9f4c5d fix: static ipv6 endpoint bug 2025-03-14 22:49:08 -04:00
Zane Schepke e32a99db77 chore: fix fi 2025-03-08 23:37:16 -05:00
Zane Schepke 6670a62e2f chore: fix fastlane 2025-03-08 23:07:02 -05:00
Zane Schepke 34b20bd7f7 fix: pt region localization 2025-03-08 22:52:53 -05:00
120 changed files with 3156 additions and 1925 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 -1
View File
@@ -39,4 +39,4 @@
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
+1 -1
View File
@@ -58,4 +58,4 @@
-dontwarn org.joda.time.Instant
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
-dontwarn org.slf4j.impl.StaticMarkerBinder
@@ -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
}
@@ -6,6 +6,7 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -21,6 +22,9 @@ class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
lateinit var tunnelManager: TunnelManager
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@@ -39,13 +43,13 @@ class ShortcutsActivity : ComponentActivity() {
.firstOrNull { it.tunName == tunnelName }
} ?: appDataRepository.getStartTunnelConfig()
Timber.d("Shortcut action on name: ${tunnelConfig?.tunName}")
// tunnelConfig?.let {
// when (intent.action) {
// Action.START.name -> tunnelService.get().startTunnel(it)
// Action.STOP.name -> tunnelService.get().stopTunnel()
// else -> Unit
// }
// }
tunnelConfig?.let {
when (intent.action) {
Action.START.name -> tunnelManager.startTunnel(it)
Action.STOP.name -> tunnelManager.stopTunnel()
else -> Unit
}
}
}
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
when (intent.action) {
@@ -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(
@@ -26,15 +26,62 @@ data class TunnelConf(
val pingCooldown: Long? = null,
val pingIp: String? = null,
val isEthernetTunnel: Boolean = false,
val isIpv4Preferred: Boolean = false,
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[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))
}
}
}
}
}
+15 -2
View File
@@ -162,7 +162,7 @@
<string name="monitoring_state_changes">Überwache Statusänderungen</string>
<string name="stop_on_no_internet">Stoppen wenn keine Internetverbindung besteht</string>
<string name="ethernet_tunnel">Ethernet Tunnel</string>
<string name="set_ethernet_tunnel">Als Ethernet Tunnel setzten</string>
<string name="set_ethernet_tunnel">Als Ethernet Tunnel setzen</string>
<string name="native_kill_switch">Nativer Notschalter</string>
<string name="vpn_kill_switch">VPN Notschalter</string>
<string name="kill_switch_options">Notschalteroptionen</string>
@@ -192,4 +192,17 @@
<string name="hide_amnezia_properties">Amnezia Eigenschaften verbergen</string>
<string name="include_lan">LAN einschließen</string>
<string name="exclude_lan">LAN ausschließen</string>
</resources>
<string name="tunnel_control">Tunnelsteuerung</string>
<string name="kill_switch_off">Notschalter stoppen bei vertrauenswürdigen</string>
<string name="error_tunnel_start">Start des Tunnels fehlgeschlagen</string>
<string name="auto_tunnel">Auto-Tunnel</string>
<string name="export_amnezia">Als Amnezia exportieren</string>
<string name="export_wireguard">Als WireGuard exportieren</string>
<string name="server_ipv4">IPv4 Hostnamensauflösung</string>
<string name="prefer_ipv4">IPv4 Verbindung bevorzugen</string>
<string name="dns_error">Fehler bei Endpunkt DNS Auflösung.</string>
<string name="start_failed_config">Starten des Tunnels wegen Konfigfehler fehlgeschlagen.</string>
<string name="unauthorized">Starten des Tunnels fehlgeschlagen, unberechtigt.</string>
<string name="tunne_start_failed_title">Tunnelfehler</string>
<string name="multiple">Mehrere</string>
</resources>
+14 -1
View File
@@ -192,4 +192,17 @@
<string name="wg_compat_mode">Modo compatibilidad de WG</string>
<string name="quick_actions">Acciones rápidas</string>
<string name="remove_amnezia_compatibility">Eliminar compatibilidad con Amnezia</string>
</resources>
<string name="error_tunnel_start">Fallo al iniciar el túnel</string>
<string name="tunnel_control">Control del túnel</string>
<string name="auto_tunnel">Túnel automático</string>
<string name="kill_switch_off">Detener interruptor de apagado en confianza</string>
<string name="server_ipv4">Resolución de host IPv4</string>
<string name="prefer_ipv4">Preferir conexión IPv4</string>
<string name="dns_error">No se ha podido resolver el DNS del punto final.</string>
<string name="start_failed_config">Fallo al iniciar túnel con error de configuración.</string>
<string name="unauthorized">Fallo al iniciar túnel, no autorizado.</string>
<string name="tunne_start_failed_title">Fallo del túnel</string>
<string name="multiple">Múltiple</string>
<string name="export_amnezia">Exportar cómo Amnezia</string>
<string name="export_wireguard">Exportar cómo WireGuard</string>
</resources>
+72 -3
View File
@@ -13,8 +13,8 @@
<string name="error_no_file_explorer">Nessun esploratore di file installato</string>
<string name="error_invalid_code">QR code non valido</string>
<string name="app_name">Tunnel WG</string>
<string name="vpn_channel_name">Canale di notifica VPN</string>
<string name="turn_off_tunnel">L\'operaz. richiede la disatt. del tunnel</string>
<string name="vpn_channel_name">Canale di notifica VPN</string>
<string name="turn_off_tunnel">L\'operaz. richiede la disatt. del tunnel</string>
<string name="public_key">Chiave pubblica</string>
<string name="addresses">Indirizzi</string>
<string name="dns_servers">Server DNS</string>
@@ -41,7 +41,7 @@
<string name="always_on_vpn_support">Permetti VPN sempre attiva</string>
<string name="location_services_not_detected">Servizi di localizzazione non rilevati</string>
<string name="hint_search_packages">Cerca pacchetti</string>
<string name="auto_tunneling">Tunnel automatico</string>
<string name="auto_tunneling">Tunnel automatico</string>
<string name="vpn_on">VPN on</string>
<string name="vpn_off">VPN off</string>
<string name="create_import">Crea da zero</string>
@@ -56,4 +56,73 @@
<string name="use_kernel">Usa modulo kernel</string>
<string name="error_ssid_exists">L\'SSID esiste già</string>
<string name="error_root_denied">Shell di root negata</string>
<string name="prominent_background_location_title">Permesso localizzazione in background</string>
<string name="location_services_missing_message">Questa app non rileva nessun servizio di localizzazione attiva sul tuo dispositivo. Dipendentemente dal dispositivo, questo potrebbe causare il fallimento a leggere il nome wifi da parte della funzione di wifi non fidate. Vuoi continuare comunque?</string>
<string name="read_logs">Leggi i log</string>
<string name="auto">(auto)</string>
<string name="underload_packet_magic_header">Magic header pacchetto sottocarico</string>
<string name="unsure_how">se non sei sicuro di come procedere</string>
<string name="vpn_settings">Impostazioni sistema VPN</string>
<string name="always_on_message">Permessi connessione VPN negati. Verifica la</string>
<string name="chat_description">Unisciti alla community</string>
<string name="junk_packet_maximum_size">Dimensione massima pacchetti indesiderati</string>
<string name="delete_tunnel">Cancella tunnel</string>
<string name="init_packet_junk_size">Inizializza la dimensione dei pacchetti indesiderati</string>
<string name="always_on_message2">per essere sicuro che la VPN Always-on sia spenta per tutte le altre app e riprova</string>
<string name="background_location_message">Permetti i permessi per la localizzazione durante tutto il tempo e/o la localizzazione precisa è richiesta per questa funzione. Vedi la</string>
<string name="config_changes_saved">Salvate modifiche configurazione.</string>
<string name="delete_tunnel_message">Sei certo di voler cancellare questo tunnel?</string>
<string name="thank_you">Grazie per usare WG Tunnel!</string>
<string name="trusted_ssid_value_description">Invia SSID</string>
<string name="exclude">Escludi</string>
<string name="include">Includi</string>
<string name="yes">Si</string>
<string name="all">tutte</string>
<string name="no_email_detected">Nessuna app email rilevata</string>
<string name="no_browser_detected">Nessun browser rilevato</string>
<string name="incorrect_pin">Il PIN non è corretto</string>
<string name="pin_created">PIN correttamente creato</string>
<string name="enable_app_lock">Abilita blocco app</string>
<string name="restart_on_ping">Riavvia su fallimento ping (beta)</string>
<string name="mobile_data_tunnel">Imposta come tunnel dati mobili</string>
<string name="set_primary_tunnel">Imposta come tunnel principale</string>
<string name="use_tunnel_on_wifi_name">Usa tunnel su nome wifi</string>
<string name="edit_tunnel">Modifica tunnel</string>
<string name="version">Versione</string>
<string name="settings">Impostazioni</string>
<string name="support">Supporto</string>
<string name="kernel">Kernel</string>
<string name="junk_packet_count">Numero pacchetti indesiderati</string>
<string name="junk_packet_minimum_size">Dimensione minima pacchetti indesiderati</string>
<string name="response_packet_junk_size">Dimensione pacchetto indesiderato risposta</string>
<string name="init_packet_magic_header">Magic header pacchetto inizializzazione</string>
<string name="response_packet_magic_header">Magic header pacchetto risposta</string>
<string name="getting_started_guide">guida di avvio rapido</string>
<string name="see_the">Vedi la</string>
<string name="error_file_format">Formato configurazione tunnel non valido</string>
<string name="restart_at_boot">Riavvia al boot</string>
<string name="vpn_denied_dialog_title">Permesso Negato</string>
<string name="tunnel_required">Questa funzione richiede almeno un tunnel</string>
<string name="app_settings">impostazioni app</string>
<string name="background_location_message2">per assicurarti che questi permessi siano abilitati</string>
<string name="root_accepted">Accesso alla shell root accettata</string>
<string name="set_custom_ping_ip">Imposta ip ping personalizzato</string>
<string name="default_ping_ip">(opzionale, default ai peers)</string>
<string name="set_custom_ping_internal">Intervallo ping (sec)</string>
<string name="optional_default">"opzionale, default: "</string>
<string name="set_custom_ping_cooldown">Tempo attesa prima della ripartenza ping (sec)</string>
<string name="show_amnezia_properties">Mostra proprietà Amnezia</string>
<string name="add_tunnels_text">Aggiungi da file o zip</string>
<string name="open_file">Apri file</string>
<string name="add_from_qr">Aggiungi da codice QR</string>
<string name="qr_scan">Scansione QR</string>
<string name="tunnel_name">Nome Tunnel</string>
<string name="tunneling_apps">App nel tunnel</string>
<string name="open_issue">Apri un problema</string>
<string name="enter_pin">Inserisci il tuo PIN</string>
<string name="create_pin">Crea PIN</string>
<string name="transport_packet_magic_header">Magic header pacchetto trasporto</string>
<string name="kill_switch_off">Ferma interruttore di spegnimento su fidate</string>
<string name="prominent_background_location_message">Questa caratteristica richiede il permesso di localizzazione in background per abilitare il monitoraggio dell\'SSID Wi-fi anche quando l\'applicazione è chiusa. Per più dettagli, verifica la Privacy Policy linkata nella schermata di supporto.</string>
<string name="auto_tunnel_title">Servizio auto-tunnel</string>
</resources>
+11 -1
View File
@@ -3,7 +3,7 @@
<string name="tunnels">Tunele</string>
<string name="app_name">WG Tunnel</string>
<string name="unsure_how">jeśli nie masz pewności, jak postępować</string>
<string name="getting_started_guide">przewodnik wprowadzający</string>
<string name="getting_started_guide">przewodnik wprowadzający,</string>
<string name="peer">Peer</string>
<string name="background_location_message2">w celu upewnienia się, że uprawnienia te są włączone</string>
<string name="rotate_keys">Rotuj klucze</string>
@@ -196,4 +196,14 @@
<string name="error_tunnel_start">Nie udało się uruchomić tunelu</string>
<string name="tunnel_control">Kontrola tunelu</string>
<string name="auto_tunnel">Autotunel</string>
<string name="kill_switch_off">Zatrzymaj wyłącznik awaryjny w zaufanej</string>
<string name="server_ipv4">Rozpoznawanie nazw hostów IPv6</string>
<string name="prefer_ipv4">Preferuj połączenie IPv4</string>
<string name="unauthorized">Nie udało się uruchomić tunelu, brak autoryzacji.</string>
<string name="multiple">Wiele</string>
<string name="start_failed_config">Nie udało się uruchomić tunelu z powodu błędu konfiguracji.</string>
<string name="tunne_start_failed_title">Awaria tunelu</string>
<string name="dns_error">Nie udało się rozpoznać punktu końcowego DNS.</string>
<string name="export_amnezia">Eksportuj jako Amnezia</string>
<string name="export_wireguard">Eksportuj jako WireGuard</string>
</resources>
+176 -1
View File
@@ -1,2 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="tunnel_on_ethernet">Túnel na ethernet</string>
<string name="public_key">Chave pública</string>
<string name="addresses">Endereços</string>
<string name="dns_servers">Servidores DNS</string>
<string name="endpoint">Endpoint</string>
<string name="name">Nome</string>
<string name="create_import">Criar do zero</string>
<string name="rotate_keys">Revezar chaves</string>
<string name="private_key">Chave privada</string>
<string name="base64_key">Chave base64</string>
<string name="optional_no_recommend">(opcional, não recomendado)</string>
<string name="preshared_key">Chave pré-partilhada</string>
<string name="seconds">segundos</string>
<string name="export_configs">Exportar configurações</string>
<string name="error_no_file_explorer">Nenhum explorador de ficheiros instalado</string>
<string name="error_invalid_code">Código QR inválido</string>
<string name="auto_tunnel_title">Serviço de Auto-túnel</string>
<string name="all">todos</string>
<string name="enter_pin">Digite o seu pin</string>
<string name="use_tunnel_on_wifi_name">Usar túnel em wifi com nome</string>
<string name="version">Versão</string>
<string name="junk_packet_count">Quantidade de pacotes-lixo</string>
<string name="junk_packet_minimum_size">Tamanho mínimo de pacote-lixo</string>
<string name="junk_packet_maximum_size">Tamanho máximo de pacote-lixo</string>
<string name="init_packet_junk_size">Tamanho de pacote-lixo inicial</string>
<string name="response_packet_junk_size">Tamanho de resposta de pacote-lixo</string>
<string name="app_name">WG Tunnel</string>
<string name="no_tunnels">Nenhum túnel foi adicionado!</string>
<string name="error_file_extension">O ficheiro não é .conf ou .zip</string>
<string name="prominent_background_location_message">Este recurso precisa de permissões de localização em segundo plano para ativar o monitoramento do SSID da rede Wi-Fi mesmo quando a aplicação está fechado. Para mais pormenores, por favor veja a Política de Privacidade no ecrã de Suporte.</string>
<string name="turn_off_tunnel">Esta ação só é possível com o túnel inativo</string>
<string name="enabled_app_shortcuts">Ativar atalhos de aplicações</string>
<string name="tunnels">Túneis</string>
<string name="privacy_policy">Ver a Política de Privacidade</string>
<string name="okay">OK</string>
<string name="tunnel_mobile_data">Túnel em dados móveis</string>
<string name="prominent_background_location_title">Revelar a localização em segundo plano</string>
<string name="thank_you">Obrigado por usar o WG Tunnel!</string>
<string name="trusted_ssid_value_description">Envie o SSID</string>
<string name="open_file">Abrir Ficheiro</string>
<string name="add_from_qr">Adicionar a partir de código QR</string>
<string name="add_tunnels_text">Adicionar a partir de ficheiro ou zip</string>
<string name="qr_scan">Escanear o código QR</string>
<string name="tunnel_name">Nome do Túnel</string>
<string name="config_changes_saved">Mudanças nas configurações gravadas.</string>
<string name="exclude">Excluir</string>
<string name="include">Incluir</string>
<string name="mtu">MTU</string>
<string name="always_on_vpn_support">Permitir VPN sempre ligada</string>
<string name="allowed_ips">IPs Permitidos</string>
<string name="peer">Par</string>
<string name="location_services_not_detected">Serviço de localização não foi detetado</string>
<string name="hint_search_packages">Procurar pacotes</string>
<string name="auto_tunneling">Auto-túnel</string>
<string name="vpn_on">VPN ligada</string>
<string name="vpn_off">VPN desligada</string>
<string name="listen_port">Porta de escuta</string>
<string name="turn_on_tunnel">Esta ação precisa um túnel ativo</string>
<string name="add_peer">Adicionar par</string>
<string name="interface_">Interface</string>
<string name="copy_public_key">Copiar chave pública</string>
<string name="comma_separated_list">Lista separada por vírgulas</string>
<string name="optional">(opcional)</string>
<string name="random">(aleatório)</string>
<string name="persistent_keepalive">Manter a conexão persistente (keepalive)</string>
<string name="cancel">Cancelar</string>
<string name="error_authentication_failed">Autenticação falhou</string>
<string name="error_authorization_failed">Autorização falhou</string>
<string name="restart_on_ping">Reiniciar em falha de ping (beta)</string>
<string name="email_description">Me envie um email</string>
<string name="error_ssid_exists">SSID já existe</string>
<string name="delete_tunnel_message">Tem certeza que quer apagar este túnel?</string>
<string name="yes">Sim</string>
<string name="unknown_error">Ocorreu um erro desconhecido</string>
<string name="email_subject">Apoio para o WG Tunnel</string>
<string name="tunnel_on_wifi">Túnel em Wi-Fi não confiável</string>
<string name="delete_tunnel">Apagar túnel</string>
<string name="email_chooser">Enviar um email…</string>
<string name="use_kernel">Usar o módulo do kernel</string>
<string name="docs_description">Ler a documentação</string>
<string name="error_root_denied">Shell Root negado</string>
<string name="location_services_missing_message">A aplicação não detetou o serviço de localização ativado no seu dispositivo. Dependendo do dispositivo, isto pode causar que a função de Wi-Fi não confiável falhe em ler o nome do Wi-Fi. Quer continuar mesmo assim?</string>
<string name="open_issue">Abrir um problema</string>
<string name="tunneling_apps">Aplicações com túnel</string>
<string name="no_email_detected">Nenhuma aplicação de email detetado</string>
<string name="no_browser_detected">Nenhum navegador detetado</string>
<string name="incorrect_pin">O Pin está errado</string>
<string name="auto">(automático)</string>
<string name="read_logs">Ler os registos</string>
<string name="pin_created">Pin criado com sucesso</string>
<string name="create_pin">Criar um pin</string>
<string name="enable_app_lock">Ligar bloqueio de aplicação</string>
<string name="edit_tunnel">Editar túnel</string>
<string name="mobile_data_tunnel">Selecionar como túnel em dados móveis</string>
<string name="set_primary_tunnel">Selecionar como túnel principal</string>
<string name="support">Suporte</string>
<string name="kernel">Kernel</string>
<string name="settings">Configurações</string>
<string name="unsure_how">se não tiver certeza em como continuar</string>
<string name="see_the">Veja o</string>
<string name="getting_started_guide">guia de início rápido</string>
<string name="error_file_format">Formato de configuração inválido</string>
<string name="vpn_channel_name">Canal de notificações VPN</string>
<string name="set_custom_ping_ip">Definir ip ping personalizado</string>
<string name="vpn_denied_dialog_title">Permissão negada</string>
<string name="vpn_settings">Configurações do sistema VPN</string>
<string name="always_on_message">A permissão de conexão VPN foi negada. Por favor, verifique</string>
<string name="chat_description">Junte-se à comunidade</string>
<string name="tunnel_required">Característica requer pelo menos um túnel</string>
<string name="app_settings">configurações da app</string>
<string name="background_location_message2">para garantir que essas permissões estejam ativadas.</string>
<string name="root_accepted">Shell root aceito</string>
<string name="optional_default">"opcional, padrão: "</string>
<string name="show_amnezia_properties">Mostrar propriedades de Amnezia</string>
<string name="never">nunca</string>
<string name="default_ping_ip">(opcional, padrão para pares)</string>
<string name="set_custom_ping_internal">Intervalo de Ping (seg)</string>
<string name="handshake">handshake</string>
<string name="background_location_message">Permitir que toda a permissão de localização do tempo e/ou localização precisa é necessária para este recurso. Por favor, veja</string>
<string name="sec">seg</string>
<string name="notifications">Notificações</string>
<string name="exclude_lan">Excluir LAN</string>
<string name="hide_scripts">Ocultar scripts</string>
<string name="trusted_wifi_names">Nomes de Wi-Fi confiáveis</string>
<string name="hide_amnezia_properties">Ocultar propriedades Amnezia</string>
<string name="remove_amnezia_compatibility">Remover compatibilidade Amnezia</string>
<string name="include_lan">Incluir LAN</string>
<string name="language">Idioma</string>
<string name="add_wifi_name">Adicionar nome Wi-Fi</string>
<string name="display_theme">Tema</string>
<string name="on_demand_rules">Regras de tunelamento sob demanda</string>
<string name="dark">Escuro</string>
<string name="dynamic">Dinâmico</string>
<string name="skip">Pular</string>
<string name="mobile_tunnel">Túnel com dados móveis</string>
<string name="requires_app_relaunch">Para aplicar as mudanças é necessário reiniciar o aplicativo. Deseja prosseguir ?</string>
<string name="add_from_clipboard">Adicionar da área de transferência</string>
<string name="restart_at_boot">Ativar na inicialização</string>
<string name="appearance">Aparência</string>
<string name="automatic">Automático</string>
<string name="light">Claro</string>
<string name="wildcards_active">Wildcards ativos</string>
<string name="learn_more">Saber mais</string>
<string name="use_wildcards">Usar nomes coringas</string>
<string name="wifi_name_via_shell">Nome do Wi-Fi por shell</string>
<string name="use_root_shell_for_wifi">Obter o nome do Wi-Fi através do shell root</string>
<string name="start_auto">Iniciar túnel automático</string>
<string name="stop_auto">Pausar túnel automático</string>
<string name="monitoring_state_changes">Monitorar status de alterações</string>
<string name="tunnel_running">Túnel em execução</string>
<string name="donate">Contribua com esse projeto</string>
<string name="local_logging">Registro local</string>
<string name="enable_local_logging">Ativar registro local</string>
<string name="configuration_change">Configuração alterada</string>
<string name="stop_on_no_internet">Interromper quando não há internet</string>
<string name="stop_on_internet_loss">Interrompa o túnel quando a internet não estiver disponível</string>
<string name="ethernet_tunnel">Túnel ethernet</string>
<string name="set_ethernet_tunnel">Definir como túnel ethernet</string>
<string name="native_kill_switch">Interruptor de desligamento padrão</string>
<string name="vpn_kill_switch">Interruptor de desligamento VPN</string>
<string name="kill_switch_options">Opções do interruptor de desligamento</string>
<string name="allow_lan_traffic">Permitir tráfego LAN</string>
<string name="bypass_lan_for_kill_switch">Ignorar LAN no interruptor de desligamento</string>
<string name="splt_tunneling">Tunelamento dividido</string>
<string name="stop">pausar</string>
<string name="tunnel_specific_settings">Configurações específicas no túnel</string>
<string name="show_scripts">Mostrar scripts</string>
<string name="amnezia_kernel_message">Amnezia indisponível no kernel</string>
<string name="enable_amnezia">Ativar Amnezia</string>
<string name="wg_compat_mode">Modo de compatibilidade WG</string>
<string name="quick_actions">Ações rápidas</string>
<string name="kernel_not_supported">Kernel não suportado</string>
<string name="advanced_settings">Configurações avançadas</string>
<string name="enable_amnezia_compatibility">Ativar compatibilidade Amnezia</string>
</resources>
-177
View File
@@ -1,177 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tunnel_on_ethernet">Túnel na ethernet</string>
<string name="public_key">Chave pública</string>
<string name="addresses">Endereços</string>
<string name="dns_servers">Servidores DNS</string>
<string name="endpoint">Endpoint</string>
<string name="name">Nome</string>
<string name="create_import">Criar do zero</string>
<string name="rotate_keys">Revezar chaves</string>
<string name="private_key">Chave privada</string>
<string name="base64_key">Chave base64</string>
<string name="optional_no_recommend">(opcional, não recomendado)</string>
<string name="preshared_key">Chave pré-partilhada</string>
<string name="seconds">segundos</string>
<string name="export_configs">Exportar configurações</string>
<string name="error_no_file_explorer">Nenhum explorador de ficheiros instalado</string>
<string name="error_invalid_code">Código QR inválido</string>
<string name="auto_tunnel_title">Serviço de Auto-túnel</string>
<string name="all">todos</string>
<string name="enter_pin">Digite o seu pin</string>
<string name="use_tunnel_on_wifi_name">Usar túnel em wifi com nome</string>
<string name="version">Versão</string>
<string name="junk_packet_count">Quantidade de pacotes-lixo</string>
<string name="junk_packet_minimum_size">Tamanho mínimo de pacote-lixo</string>
<string name="junk_packet_maximum_size">Tamanho máximo de pacote-lixo</string>
<string name="init_packet_junk_size">Tamanho de pacote-lixo inicial</string>
<string name="response_packet_junk_size">Tamanho de resposta de pacote-lixo</string>
<string name="app_name">WG Tunnel</string>
<string name="no_tunnels">Nenhum túnel foi adicionado!</string>
<string name="error_file_extension">O ficheiro não é .conf ou .zip</string>
<string name="prominent_background_location_message">Este recurso precisa de permissões de localização em segundo plano para ativar o monitoramento do SSID da rede Wi-Fi mesmo quando a aplicação está fechado. Para mais pormenores, por favor veja a Política de Privacidade no ecrã de Suporte.</string>
<string name="turn_off_tunnel">Esta ação só é possível com o túnel inativo</string>
<string name="enabled_app_shortcuts">Ativar atalhos de aplicações</string>
<string name="tunnels">Túneis</string>
<string name="privacy_policy">Ver a Política de Privacidade</string>
<string name="okay">OK</string>
<string name="tunnel_mobile_data">Túnel em dados móveis</string>
<string name="prominent_background_location_title">Revelar a localização em segundo plano</string>
<string name="thank_you">Obrigado por usar o WG Tunnel!</string>
<string name="trusted_ssid_value_description">Envie o SSID</string>
<string name="open_file">Abrir Ficheiro</string>
<string name="add_from_qr">Adicionar a partir de código QR</string>
<string name="add_tunnels_text">Adicionar a partir de ficheiro ou zip</string>
<string name="qr_scan">Escanear o código QR</string>
<string name="tunnel_name">Nome do Túnel</string>
<string name="config_changes_saved">Mudanças nas configurações gravadas.</string>
<string name="exclude">Excluir</string>
<string name="include">Incluir</string>
<string name="mtu">MTU</string>
<string name="always_on_vpn_support">Permitir VPN sempre ligada</string>
<string name="allowed_ips">IPs Permitidos</string>
<string name="peer">Par</string>
<string name="location_services_not_detected">Serviço de localização não foi detetado</string>
<string name="hint_search_packages">Procurar pacotes</string>
<string name="auto_tunneling">Auto-túnel</string>
<string name="vpn_on">VPN ligada</string>
<string name="vpn_off">VPN desligada</string>
<string name="listen_port">Porta de escuta</string>
<string name="turn_on_tunnel">Esta ação precisa um túnel ativo</string>
<string name="add_peer">Adicionar par</string>
<string name="interface_">Interface</string>
<string name="copy_public_key">Copiar chave pública</string>
<string name="comma_separated_list">Lista separada por vírgulas</string>
<string name="optional">(opcional)</string>
<string name="random">(aleatório)</string>
<string name="persistent_keepalive">Manter a conexão persistente (keepalive)</string>
<string name="cancel">Cancelar</string>
<string name="error_authentication_failed">Autenticação falhou</string>
<string name="error_authorization_failed">Autorização falhou</string>
<string name="restart_on_ping">Reiniciar em falha de ping (beta)</string>
<string name="email_description">Me envie um email</string>
<string name="error_ssid_exists">SSID já existe</string>
<string name="delete_tunnel_message">Tem certeza que quer apagar este túnel?</string>
<string name="yes">Sim</string>
<string name="unknown_error">Ocorreu um erro desconhecido</string>
<string name="email_subject">Apoio para o WG Tunnel</string>
<string name="tunnel_on_wifi">Túnel em Wi-Fi não confiável</string>
<string name="delete_tunnel">Apagar túnel</string>
<string name="email_chooser">Enviar um email…</string>
<string name="use_kernel">Usar o módulo do kernel</string>
<string name="docs_description">Ler a documentação</string>
<string name="error_root_denied">Shell Root negado</string>
<string name="location_services_missing_message">A aplicação não detetou o serviço de localização ativado no seu dispositivo. Dependendo do dispositivo, isto pode causar que a função de Wi-Fi não confiável falhe em ler o nome do Wi-Fi. Quer continuar mesmo assim?</string>
<string name="open_issue">Abrir um problema</string>
<string name="tunneling_apps">Aplicações com túnel</string>
<string name="no_email_detected">Nenhuma aplicação de email detetado</string>
<string name="no_browser_detected">Nenhum navegador detetado</string>
<string name="incorrect_pin">O Pin está errado</string>
<string name="auto">(automático)</string>
<string name="read_logs">Ler os registos</string>
<string name="pin_created">Pin criado com sucesso</string>
<string name="create_pin">Criar um pin</string>
<string name="enable_app_lock">Ligar bloqueio de aplicação</string>
<string name="edit_tunnel">Editar túnel</string>
<string name="mobile_data_tunnel">Selecionar como túnel em dados móveis</string>
<string name="set_primary_tunnel">Selecionar como túnel principal</string>
<string name="support">Suporte</string>
<string name="kernel">Kernel</string>
<string name="settings">Configurações</string>
<string name="unsure_how">se não tiver certeza em como continuar</string>
<string name="see_the">Veja o</string>
<string name="getting_started_guide">guia de início rápido</string>
<string name="error_file_format">Formato de configuração inválido</string>
<string name="vpn_channel_name">Canal de notificações VPN</string>
<string name="set_custom_ping_ip">Definir ip ping personalizado</string>
<string name="vpn_denied_dialog_title">Permissão negada</string>
<string name="vpn_settings">Configurações do sistema VPN</string>
<string name="always_on_message">A permissão de conexão VPN foi negada. Por favor, verifique</string>
<string name="chat_description">Junte-se à comunidade</string>
<string name="tunnel_required">Característica requer pelo menos um túnel</string>
<string name="app_settings">configurações da app</string>
<string name="background_location_message2">para garantir que essas permissões estejam ativadas.</string>
<string name="root_accepted">Shell root aceito</string>
<string name="optional_default">"opcional, padrão: "</string>
<string name="show_amnezia_properties">Mostrar propriedades de Amnezia</string>
<string name="never">nunca</string>
<string name="default_ping_ip">(opcional, padrão para pares)</string>
<string name="set_custom_ping_internal">Intervalo de Ping (seg)</string>
<string name="handshake">handshake</string>
<string name="background_location_message">Permitir que toda a permissão de localização do tempo e/ou localização precisa é necessária para este recurso. Por favor, veja</string>
<string name="sec">seg</string>
<string name="notifications">Notificações</string>
<string name="exclude_lan">Excluir LAN</string>
<string name="hide_scripts">Ocultar scripts</string>
<string name="trusted_wifi_names">Nomes de Wi-Fi confiáveis</string>
<string name="hide_amnezia_properties">Ocultar propriedades Amnezia</string>
<string name="remove_amnezia_compatibility">Remover compatibilidade Amnezia</string>
<string name="include_lan">Incluir LAN</string>
<string name="language">Idioma</string>
<string name="add_wifi_name">Adicionar nome Wi-Fi</string>
<string name="display_theme">Tema</string>
<string name="on_demand_rules">Regras de tunelamento sob demanda</string>
<string name="dark">Escuro</string>
<string name="dynamic">Dinâmico</string>
<string name="skip">Pular</string>
<string name="mobile_tunnel">Túnel com dados móveis</string>
<string name="requires_app_relaunch">Para aplicar as mudanças é necessário reiniciar o aplicativo. Deseja prosseguir ?</string>
<string name="add_from_clipboard">Adicionar da área de transferência</string>
<string name="restart_at_boot">Ativar na inicialização</string>
<string name="appearance">Aparência</string>
<string name="automatic">Automático</string>
<string name="light">Claro</string>
<string name="wildcards_active">Wildcards ativos</string>
<string name="learn_more">Saber mais</string>
<string name="use_wildcards">Usar nomes coringas</string>
<string name="wifi_name_via_shell">Nome do Wi-Fi por shell</string>
<string name="use_root_shell_for_wifi">Obter o nome do Wi-Fi através do shell root</string>
<string name="start_auto">Iniciar túnel automático</string>
<string name="stop_auto">Pausar túnel automático</string>
<string name="monitoring_state_changes">Monitorar status de alterações</string>
<string name="tunnel_running">Túnel em execução</string>
<string name="donate">Contribua com esse projeto</string>
<string name="local_logging">Registro local</string>
<string name="enable_local_logging">Ativar registro local</string>
<string name="configuration_change">Configuração alterada</string>
<string name="stop_on_no_internet">Interromper quando não há internet</string>
<string name="stop_on_internet_loss">Interrompa o túnel quando a internet não estiver disponível</string>
<string name="ethernet_tunnel">Túnel ethernet</string>
<string name="set_ethernet_tunnel">Definir como túnel ethernet</string>
<string name="native_kill_switch">Interruptor de desligamento padrão</string>
<string name="vpn_kill_switch">Interruptor de desligamento VPN</string>
<string name="kill_switch_options">Opções do interruptor de desligamento</string>
<string name="allow_lan_traffic">Permitir tráfego LAN</string>
<string name="bypass_lan_for_kill_switch">Ignorar LAN no interruptor de desligamento</string>
<string name="splt_tunneling">Tunelamento dividido</string>
<string name="stop">pausar</string>
<string name="tunnel_specific_settings">Configurações específicas no túnel</string>
<string name="show_scripts">Mostrar scripts</string>
<string name="amnezia_kernel_message">Amnezia indisponível no kernel</string>
<string name="enable_amnezia">Ativar Amnezia</string>
<string name="wg_compat_mode">Modo de compatibilidade WG</string>
<string name="quick_actions">Ações rápidas</string>
<string name="kernel_not_supported">Kernel não suportado</string>
<string name="advanced_settings">Configurações avançadas</string>
<string name="enable_amnezia_compatibility">Ativar compatibilidade Amnezia</string>
</resources>
+16 -3
View File
@@ -3,7 +3,7 @@
<string name="turn_off_tunnel">Действие требует отключения туннеля</string>
<string name="mtu">MTU</string>
<string name="tunnel_name">Имя туннеля</string>
<string name="public_key">Публичный ключ</string>
<string name="public_key">Открытый ключ</string>
<string name="name">Имя</string>
<string name="peer">Пир</string>
<string name="privacy_policy">Посмотреть политику конфиденциальности</string>
@@ -129,7 +129,7 @@
<string name="handshake">рукопожатие</string>
<string name="logs">Журналы</string>
<string name="light">Светлая</string>
<string name="automatic">Автоматически</string>
<string name="automatic">Автоматическая</string>
<string name="dynamic">Динамическая</string>
<string name="language">Язык</string>
<string name="trusted_wifi_names">Доверенные сети Wi-Fi</string>
@@ -192,4 +192,17 @@
<string name="advanced_settings">Дополнительные настройки</string>
<string name="enable_amnezia_compatibility">Включить совместимость с Amnezia</string>
<string name="include_lan">Включить LAN</string>
</resources>
<string name="auto_tunnel">Автотуннель</string>
<string name="tunnel_control">Управление туннелем</string>
<string name="error_tunnel_start">Невозможно запустить туннель</string>
<string name="kill_switch_off">Без экстренного отключения в доверенных</string>
<string name="prefer_ipv4">Предпочитать соединение IPv4</string>
<string name="dns_error">Не получить конечную точку DNS.</string>
<string name="start_failed_config">Невозможно запустить туннель с ошибкой конфигурации.</string>
<string name="unauthorized">Невозможно запустить туннель без авторизации.</string>
<string name="tunne_start_failed_title">Ошибка туннеля</string>
<string name="server_ipv4">Получение имени узла IPv4</string>
<string name="multiple">Несколько</string>
<string name="export_amnezia">Экспортировать как Amnezia</string>
<string name="export_wireguard">Экспортировать как WireGuard</string>
</resources>
+103 -5
View File
@@ -30,7 +30,7 @@
<string name="getting_started_guide">інструкція щодо початку роботи</string>
<string name="error_file_format">некоректний формат конфігурації тунелю</string>
<string name="vpn_channel_name">Канал сповіщення VPN</string>
<string name="error_file_extension">Файл не є .conf або .zip файлом</string>
<string name="error_file_extension">Файл не є .conf або .zip файлом</string>
<string name="turn_off_tunnel">Дія потребує вимкнення тунелю</string>
<string name="tunnel_mobile_data">Тунелювати мобільні дані</string>
<string name="privacy_policy">Переглянути політику конфіденційності</string>
@@ -51,7 +51,7 @@
<string name="name">Ім\'я</string>
<string name="always_on_vpn_support">Дозволили Always-ON VPN</string>
<string name="location_services_not_detected">Сервіси місце знаходження не знайдено</string>
<string name="auto_tunneling">Авто-тунелювання</string>
<string name="auto_tunneling">Авто-тунелювання</string>
<string name="vpn_on">VPN увімк.</string>
<string name="vpn_off">VPN вимк.</string>
<string name="create_import">Створити з нуля</string>
@@ -59,9 +59,9 @@
<string name="interface_">Інтерфейс</string>
<string name="copy_public_key">Копіювати відкритий ключ</string>
<string name="listen_port">Слухати порт</string>
<string name="preshared_key">Pre-shared key</string>
<string name="preshared_key">Загальний ключ</string>
<string name="seconds">секунд</string>
<string name="persistent_keepalive">Persistent keepalive</string>
<string name="persistent_keepalive">Підтримка роботи тунелю (keepalive)</string>
<string name="error_authorization_failed">Не вдалося авторизуватися</string>
<string name="enabled_app_shortcuts">Дозволити ярлики</string>
<string name="error_authentication_failed">Помилка автентифікації</string>
@@ -74,7 +74,7 @@
<string name="use_kernel">Використовувати модуль режиму ядра</string>
<string name="error_ssid_exists">SSID вже існує</string>
<string name="error_no_file_explorer">Не знайдено файловий менеджер</string>
<string name="auto_tunnel_title">Сервіс авто-тунелювання</string>
<string name="auto_tunnel_title">Сервіс автотунелювання</string>
<string name="delete_tunnel">Видалити тунель</string>
<string name="location_services_missing_message">Додаток не знайшов служб місце знаходження на вашому пристрої. На деяких пристроях це може привести до неможливості визначення назви мережі Wi-Fi і помилок функції недовірених Wi-Fi мереж. Все рівно хочете продовжити?</string>
<string name="delete_tunnel_message">Ви дійсно хочете видалити цей тунель?</string>
@@ -107,4 +107,102 @@
<string name="response_packet_magic_header">Заголовок пакету відповіді</string>
<string name="unsure_how">, якщо не впевнені що робити далі</string>
<string name="see_the">Дивіться</string>
<string name="skip">Пропустити</string>
<string name="trusted_wifi_names">Довірені мережі Wi-Fi</string>
<string name="vpn_denied_dialog_title">Немає дозволу</string>
<string name="app_settings">налаштування програми</string>
<string name="background_location_message2">, щоб переконатися, що ці дозволи надано</string>
<string name="default_ping_ip">(необов\'язково, за замовчуванням для пірів)</string>
<string name="set_custom_ping_internal">Інтервал пінгу (сек.)</string>
<string name="set_custom_ping_cooldown">Час очікування перезапуску пінгу (сек.)</string>
<string name="show_amnezia_properties">Показати налаштування Amnezia</string>
<string name="sec">сек.</string>
<string name="handshake">рукостискання</string>
<string name="notifications">Сповіщення</string>
<string name="light">Світла</string>
<string name="dark">Темна</string>
<string name="language">Мова</string>
<string name="add_wifi_name">Додати мережу Wi-Fi</string>
<string name="on_demand_rules">Правила тунелю на запит</string>
<string name="primary_tunnel">Основний тунель</string>
<string name="launch_app_settings">Налаштування запуску програми</string>
<string name="learn_more">Дізнатись більше</string>
<string name="wildcards_active">Підстановочні знаки використовуються</string>
<string name="wifi_name_via_shell">Ім\'я Wi-Fi через root</string>
<string name="kill_switch">Екстрене відключення</string>
<string name="restart_at_boot">Перезапуск під час завантаження</string>
<string name="vpn_settings">Системні налаштування VPN</string>
<string name="always_on_message">Дозвіл на VPN-з\'єднання було відхилено, перевірте</string>
<string name="root_accepted">Root-доступ дозволено</string>
<string name="set_custom_ping_ip">Призначити свій IP для пінгу</string>
<string name="logs">Журнали</string>
<string name="mobile_tunnel">Тунель для мобільних даних</string>
<string name="display_theme">Тема</string>
<string name="chat_description">Приєднатися до спільноти</string>
<string name="automatic">Автоматично</string>
<string name="never">ніколи</string>
<string name="appearance">Зовнішній вигляд</string>
<string name="dynamic">Динамічна</string>
<string name="use_root_shell_for_wifi">Використовувати root-доступ для отримання імені мережі Wi-Fi</string>
<string name="use_wildcards">Використовувати підстановочні знаки в імені</string>
<string name="optional_default">"необов\'язково, за замовчуванням: "</string>
<string name="always_on_message2">, щоб переконатися, що функція «Постійний VPN» вимкнена для всіх інших програм, і спробуйте ще раз</string>
<string name="tunnel_required">Для цієї функції необхідний хоча б один тунель</string>
<string name="background_location_message">Дозволяти весь час, доки для роботи цієї функції потрібен доступ на місцезнаходження та/або точне місцезнаходження. Дивіться</string>
<string name="advanced_settings">Додаткові налаштування</string>
<string name="quick_actions">Швидкі дії</string>
<string name="tunnel_running">Тунель працює</string>
<string name="monitoring_state_changes">Відстеження змін стану</string>
<string name="donate">Пожертвувати на проект</string>
<string name="requires_app_relaunch">Дана зміна потребує перезапуску програми. Продовжити?</string>
<string name="add_from_clipboard">Додати з буфера обміну</string>
<string name="stop_on_no_internet">Зупинити без інтернету</string>
<string name="native_kill_switch">Штатне екстрене відключення</string>
<string name="vpn_kill_switch">Екстрене відключення VPN</string>
<string name="kill_switch_options">Налаштування екстреного вимкнення</string>
<string name="allow_lan_traffic">Обхід LAN</string>
<string name="bypass_lan_for_kill_switch">Дозволяти трафік LAN при екстреному вимкненні</string>
<string name="auto_tunnel_channel_name">Канал сповіщень автотунелю</string>
<string name="auto_tunnel_channel_description">Канал сповіщень про стан автотунелю</string>
<string name="stop">стоп</string>
<string name="splt_tunneling">Роздільне тунелювання</string>
<string name="show_scripts">Показати сценарії</string>
<string name="pre_up">До активації</string>
<string name="post_up">Після активації</string>
<string name="pre_down">До деактивації</string>
<string name="post_down">Після деактивації</string>
<string name="amnezia_kernel_message">Amnezia недоступна у режимі ядра</string>
<string name="enable_amnezia">Використовувати Amnezia</string>
<string name="wg_compat_mode">Режим сумісності WG</string>
<string name="debounce_delay">Затримка відбою</string>
<string name="hide_amnezia_properties">Приховати налаштування Amnezia</string>
<string name="hide_scripts">Приховати сценарії</string>
<string name="remove_amnezia_compatibility">Вимкнути сумісність з Amnezia</string>
<string name="exclude_lan">Виключити LAN</string>
<string name="include_lan">Увімкнути LAN</string>
<string name="error_tunnel_start">Неможливо запустити тунель</string>
<string name="tunnel_control">Управління тунелем</string>
<string name="auto_tunnel">Автотунель</string>
<string name="local_logging">Локальне ведення журналу</string>
<string name="kernel_not_supported">Ядро не підтримується</string>
<string name="start_auto">Запустити автотунель</string>
<string name="stop_auto">Зупинити автотунель</string>
<string name="enable_local_logging">Увімкнути ведення журналу</string>
<string name="configuration_change">Зміна конфігурації</string>
<string name="stop_on_internet_loss">Зупинити тунель під час втрати інтернету</string>
<string name="ethernet_tunnel">Тунель для Ethernet</string>
<string name="set_ethernet_tunnel">Призначити як тунель для Ethernet</string>
<string name="tunnel_specific_settings">Спеціальні налаштування тунелю</string>
<string name="vpn_channel_description">Канал сповіщень про стан VPN</string>
<string name="enable_amnezia_compatibility">Включити сумісність із Amnezia</string>
<string name="kill_switch_off">Без екстреного вимкнення у довірених</string>
<string name="server_ipv4">Отримання імені вузла IPv4</string>
<string name="prefer_ipv4">Віддавати перевагу з\'єднанню IPv4</string>
<string name="dns_error">Не отримати кінцеву точку DNS.</string>
<string name="start_failed_config">Неможливо запустити тунель із помилкою конфігурації.</string>
<string name="unauthorized">Неможливо запустити тунель без авторизації.</string>
<string name="tunne_start_failed_title">Помилка тунелю</string>
<string name="multiple">Декілька</string>
<string name="export_amnezia">Експортувати як Amnezia</string>
<string name="export_wireguard">Експортувати як WireGuard</string>
</resources>
+47 -2
View File
@@ -65,7 +65,7 @@
<string name="config_changes_saved">设置已保存。</string>
<string name="interface_">接口</string>
<string name="email_subject">WG Tunnel 支持</string>
<string name="auto_tunnel_title">自动连接服务</string>
<string name="auto_tunnel_title">自动隧道服务</string>
<string name="delete_tunnel">删除隧道</string>
<string name="delete_tunnel_message">确定删除这个隧道吗?</string>
<string name="location_services_missing_message">此应用不会在您的设备上检测任何已开启的定位服务。根据不同的设备,可能会导致无法获得不可信 WiFi 的名称。您想继续吗?</string>
@@ -108,7 +108,7 @@
<string name="vpn_denied_dialog_title">拒绝访问</string>
<string name="tunnel_required">此功能需要至少一个隧道</string>
<string name="app_settings">应用设置</string>
<string name="background_location_message2">请确保这些权限已开启</string>
<string name="background_location_message2">请确保这些权限已开启</string>
<string name="logs">日志</string>
<string name="restart_on_ping">Ping 失败之后自动重启隧道(beta)</string>
<string name="edit_tunnel">编辑隧道</string>
@@ -160,4 +160,49 @@
<string name="enable_local_logging">开启本地日志</string>
<string name="configuration_change">配置更改</string>
<string name="requires_app_relaunch">此更改需要重新启动应用程序。您是否要继续?</string>
<string name="stop_on_no_internet">无网络时停用</string>
<string name="stop_on_internet_loss">网络丢失时停止隧道</string>
<string name="bypass_lan_for_kill_switch">绕过局域网流量</string>
<string name="auto_tunnel_channel_name">自动隧道通知频道</string>
<string name="auto_tunnel_channel_description">自动隧道状态通知频道</string>
<string name="stop">停止</string>
<string name="tunnel_specific_settings">隧道特殊设置</string>
<string name="splt_tunneling">隧道分流</string>
<string name="show_scripts">显示脚本</string>
<string name="pre_up">启动前</string>
<string name="post_up">启动后</string>
<string name="pre_down">关闭前</string>
<string name="post_down">关闭后</string>
<string name="amnezia_kernel_message">Amnezia 在内核模式中不可用</string>
<string name="enable_amnezia">开启 Amnezia</string>
<string name="wg_compat_mode">WG 兼容性模式</string>
<string name="quick_actions">快捷操作</string>
<string name="advanced_settings">高级设置</string>
<string name="debounce_delay">防抖延迟</string>
<string name="hide_amnezia_properties">隐藏 Amnezia 属性</string>
<string name="hide_scripts">隐藏脚本</string>
<string name="enable_amnezia_compatibility">开启 Amnezia 兼容性</string>
<string name="remove_amnezia_compatibility">移除 Amnezia 兼容性</string>
<string name="exclude_lan">排除局域网</string>
<string name="include_lan">包含局域网</string>
<string name="error_tunnel_start">开启隧道失败</string>
<string name="tunnel_control">隧道控制</string>
<string name="auto_tunnel">自动隧道</string>
<string name="ethernet_tunnel">以太网隧道</string>
<string name="set_ethernet_tunnel">设置为以太网隧道</string>
<string name="native_kill_switch">系统 VPN 开关</string>
<string name="vpn_kill_switch">VPN 开关</string>
<string name="kill_switch_options">开关选项</string>
<string name="allow_lan_traffic">允许局域网流量</string>
<string name="vpn_channel_description">VPN 状态通知频道</string>
<string name="kill_switch_off">在受信任网络上停止 Kill Switch</string>
<string name="prefer_ipv4">首选 IPv4 连接</string>
<string name="dns_error">解析端点 DNS 失败。</string>
<string name="unauthorized">身份验证未通过,启动流量隧道失败。</string>
<string name="tunne_start_failed_title">隧道失败</string>
<string name="multiple">多个</string>
<string name="server_ipv4">IPv4 主机名解析</string>
<string name="start_failed_config">配置文件错误,启动流量隧道失败。</string>
<string name="export_amnezia">导出为 Amnezia</string>
<string name="export_wireguard">导出为 WireGuard</string>
</resources>
+100 -1
View File
@@ -1,2 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="sec"></string>
<string name="error_file_extension">檔案類型不是 .conf 或 .zip</string>
<string name="language">語言</string>
<string name="interface_">界面</string>
<string name="launch_app_settings">打開應用程式設定</string>
<string name="use_kernel">使用核心模組</string>
<string name="version">版本</string>
<string name="app_name">WG Tunnel</string>
<string name="name">名稱</string>
<string name="public_key">公鑰</string>
<string name="privacy_policy">查看隱私政策</string>
<string name="tunnels">隧道列表</string>
<string name="thank_you">感謝您使用 WG Tunnel!</string>
<string name="open_file">開啟檔案</string>
<string name="mtu">MTU</string>
<string name="okay"></string>
<string name="qr_scan">掃描 QR code</string>
<string name="dns_servers">DNS 伺服器</string>
<string name="tunnel_name">隧道名稱</string>
<string name="config_changes_saved">組態變更已儲存。</string>
<string name="exclude">排除</string>
<string name="include">包含</string>
<string name="addresses">地址</string>
<string name="add_tunnels_text">從檔案或 zip 壓縮檔新增</string>
<string name="add_from_qr">從 QR code 新增</string>
<string name="copy_public_key">複製公鑰</string>
<string name="optional_no_recommend">(可選, 不建議)</string>
<string name="optional">(可選)</string>
<string name="random">(隨機)</string>
<string name="cancel">取消</string>
<string name="private_key">私鑰</string>
<string name="listen_port">監聽連接埠</string>
<string name="export_configs">匯出組態</string>
<string name="create_import">手動建立</string>
<string name="seconds"></string>
<string name="docs_description">閱讀文件</string>
<string name="error_ssid_exists">SSID 已經存在</string>
<string name="delete_tunnel">刪除隧道</string>
<string name="delete_tunnel_message">您確定要刪除此隧道?</string>
<string name="yes"></string>
<string name="error_invalid_code">無效的 QR code</string>
<string name="no_browser_detected">未安裝瀏覽器</string>
<string name="no_email_detected">未安裝電子郵件應用程式</string>
<string name="open_issue">建立新的問題</string>
<string name="auto">(自動)</string>
<string name="create_pin">建立 PIN</string>
<string name="enter_pin">輸入 PIN</string>
<string name="pin_created">成功建立 PIN</string>
<string name="incorrect_pin">PIN 不正確</string>
<string name="enable_app_lock">啟用應用程式鎖定</string>
<string name="set_primary_tunnel">設為主要隧道</string>
<string name="edit_tunnel">編輯隧道</string>
<string name="kernel">核心</string>
<string name="vpn_settings">系統 VPN 設定</string>
<string name="support">支持</string>
<string name="getting_started_guide">取得入門指南</string>
<string name="settings">設定</string>
<string name="restart_at_boot">開機時重新啟動</string>
<string name="chat_description">加入社區</string>
<string name="junk_packet_count">無效封包數</string>
<string name="set_custom_ping_internal">Ping 間隔 (秒)</string>
<string name="app_settings">應用程式設定</string>
<string name="logs">日誌</string>
<string name="dark">暗色</string>
<string name="light">亮色</string>
<string name="donate">捐贈給專案</string>
<string name="appearance">外觀</string>
<string name="display_theme">主題</string>
<string name="primary_tunnel">主要隧道</string>
<string name="learn_more">了解更多</string>
<string name="kernel_not_supported">核心不支持</string>
<string name="notifications">通知</string>
<string name="dynamic">動態</string>
<string name="never">從不</string>
<string name="automatic">自動</string>
<string name="add_wifi_name">新增WiFi SSID</string>
<string name="allow_lan_traffic">允許 LAN 流量</string>
<string name="configuration_change">組態變更</string>
<string name="stop_on_no_internet">沒有連上網路時停用</string>
<string name="stop_on_internet_loss">網路連線斷開時停止隧道</string>
<string name="add_from_clipboard">從剪貼簿新增</string>
<string name="stop">停止</string>
<string name="amnezia_kernel_message">Amnezia 無法在核心模式使用</string>
<string name="enable_amnezia">啟用 Amnezia</string>
<string name="wg_compat_mode">WG 相容性模式</string>
<string name="advanced_settings">進階設定</string>
<string name="exclude_lan">排除 LAN</string>
<string name="include_lan">包含 LAN</string>
<string name="error_tunnel_start">啟用隧道失敗</string>
<string name="unknown_error">發生未知錯誤</string>
<string name="error_file_format">無效的隧道組態檔案格式</string>
<string name="endpoint">端點</string>
<string name="location_services_not_detected">未啟用定位服務</string>
<string name="junk_packet_maximum_size">最大無效封包</string>
<string name="no_tunnels">還沒有新增任何隧道!</string>
<string name="allowed_ips">允許的 IP</string>
<string name="junk_packet_minimum_size">最小無效封包</string>
<string name="error_no_file_explorer">未安裝任何檔案管理器</string>
</resources>
+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.0"
const val VERSION_NAME = "3.7.2"
const val JVM_TARGET = "17"
const val VERSION_CODE = 37000
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,6 @@
Was ist neu?
- Die Ping-Funktion funktioniert jetzt unabhängig vom automatischen Tunnel
- Komfortaktion für Amnezia-Kompatibilität hinzugefügt
- Komfortaktion zum Ausschließen von LAN aus dem Tunnel hinzugefügt
- Option zur Einstellung der Entprellungszeit für Autotunnel hinzugefügt
- Viele Fehlerkorrekturen und Verbesserungen
@@ -0,0 +1,5 @@
What's new:
- Static IPv6 peer endpoint bug fix
- Dynamic shortcuts bug fix
- Localizations
- Add peer bug fix
@@ -0,0 +1,5 @@
What's new:
- Auto tunnel regression fix
- Tile sync improvements
- Optimize wifi name querying
- Improve network monitoring permission checks
@@ -0,0 +1,14 @@
Features
- Add tunnels via .conf file, zip, manual entry, or QR code
- Auto connect to VPN based on Wi-Fi SSID, ethernet, or mobile data
- Split tunneling by application with search
- WireGuard support for kernel and userspace modes
- Amnezia support for userspace mode for DPI/censorship protection
- Always-On VPN support
- Export Amnezia and WireGuard tunnels to zip
- Quick tile support for VPN toggling
- Static shortcuts support for primary tunnel for automation integration
- Intent automation support for all tunnels
- Automatic service restart after reboot
- Battery preservation measures
@@ -0,0 +1,6 @@
Novità:
- aggiornamenti UI
- Migliorie navigazione AndroidTV
- Bug consumo batteria risolto
- Correzione impostazioni opzionali caratteri jolly
- Altre migliorie
@@ -0,0 +1,7 @@
Novità:
- Aggiungi tunnel dagli appunti
- Aggiunte localizzazioni
- Corretto bug consumo batteria
- Corretto bug cancellazione
- Migliorata sincronizzazione tile tunnel
- Altre correzioni e migliorie
@@ -0,0 +1,5 @@
Novità:
- Aggiunto interruttore spegnimento VPN con esclusione LAN
- Migliorata velocità e stabilità auto tunnel
- Migliorata sincronizzazione tile
- Vari bug risolti e migliorie
@@ -0,0 +1,3 @@
Novità:
- Corretto bug commutatore modalità kernel
- Corretto bug crash notifica
@@ -0,0 +1,6 @@
Novità:
- Migliorata stabilità auto tunnel
- Corretto salvataggio split tunnel e migliore prestazioni
- Corretto bug modalità kernel
- Corretto bug tile
- Vare altre correzioni e migliorie
@@ -0,0 +1,6 @@
Novità:
- Il ping ora funziona indipendentemente dall'auto tunnel
- Aggiunte azioni per compatibilità Amnezia
- Aggiunte azioni esclusione LAN dal tunnel
- Aggiunta opzione regolazione tempo di rimbalzo per auto tunnel
- Molte correzioni a bug e migliore
@@ -1 +0,0 @@
Uma alternativa de cliente WireGuard VPN com recursos adicionais
@@ -0,0 +1,3 @@
Поліпшення:
- Виправлена помилка з дозволами на Android < 9
- Інші оптимізації
@@ -0,0 +1,5 @@
Поліпшення:
- Додано статистику тунелю на головний екран
- Поліпшено навігацію в екрані налаштувань на Android TV
- Видалено вібрацію при оповіщенні
- Інші виправлення
@@ -0,0 +1,5 @@
Поліпшення:
- Додана підтримка авто-тунелювання лише через мобільну мережу
- Покращено інтерфейс екрану підтримки
- Оновлено посилання на ресурси
- Інші виправлення помилок
@@ -0,0 +1,5 @@
Поліпшення:
- Базова підтримка модуля WireGuard режиму ядра
- Покращено процес розкриття інформації про місцезнаходження
- Виправлено помилку з дозволами авто-тунелю
- Інші виправлення інтерфейсу
@@ -0,0 +1,2 @@
Виправлення:
- Дозвіл переднього плану в Android 14
@@ -0,0 +1,7 @@
Поліпшення:
- Рефакторинг зберігання даних додатком
- Поліпшено навігацію в інтерфейсі для Android TV
- Збільшено ефективність авто-тунелювання
- Поліпшено навігацію
- Можливість призупинити роботу авто-тунелю
- Безліч виправлень помилок
@@ -0,0 +1,7 @@
Поліпшення:
- Рефакторинг зберігання даних додатком
- Поліпшено навігацію в інтерфейсі для Android TV
- Збільшено ефективність авто-тунелювання
- Поліпшено навігацію
- Можливість призупинити роботу авто-тунелю
- Безліч виправлень помилок
@@ -0,0 +1,8 @@
Поліпшення:
- Рефакторинг зберігання даних додатком
- Поліпшено навігацію в інтерфейсі для Android TV
- Збільшено ефективність авто-тунелювання
- Поліпшено навігацію
- Можливість призупинити роботу авто-тунелю
- Виправлено запуск авто-тунелю на передньому плані
- Безліч виправлень помилок
@@ -0,0 +1,7 @@
Поліпшення:
- Додано підтвердження під час видалення тунелю
- Додано дозвіл на роботу у фоні
Виправлення:
- Зависання програми при відключенні тунелю
- Помилка у полі отримувача електронної пошти
- Редагування конфігурації з порожнім полем DNS
@@ -0,0 +1,3 @@
Поліпшення:
- Виправлено збій збереження створених налаштувань
- Збільшено номер версії
@@ -0,0 +1,2 @@
Що нового:
- Тестова версія для налагодження Continuous Integration
@@ -0,0 +1,5 @@
Що нового:
- Автозапуск «Завжди увімкненого» VPN режиму ядра при перезавантаженні системи
- Підтримка піктограм адаптивної теми
- Виправлені значки повідомлень та плитки
- Виправлені значки Android TV
@@ -0,0 +1,5 @@
Що нового:
- Покращено процес першого запуску програми
- Перехід на альтернативну реалізацію бібліотеки WireGuard
- Запит дозволу на доступ до VPN під час першого підключення VPN
- Збільшено номер версії
@@ -0,0 +1,2 @@
Що нового:
- Виправлена помилка в інтерфейсі тунелю
@@ -0,0 +1,4 @@
Що нового:
- Виправлено помилку в інтерфейсі редактора конфігурації
- Додано повідомлення AOVPN під час першого запуску на Graphene OS
- Збільшено номер версії
@@ -0,0 +1,5 @@
Що нового:
- Доданий екран із журналами виконання
- Додано блокування програми
- Додано перезапуск тунелю при збої пінгу
- Різні виправлення помилок
@@ -0,0 +1,5 @@
Що нового:
- Авто-тунель залежно від імені мережі Wi-Fi
- Управління авто-тунелем через плитку та ярлики
- Автозапуск тунелів, активних на момент перезавантаження
- Інші виправлення та покращення продуктивності
@@ -0,0 +1,5 @@
Що нового:
- Поліпшено надійність роботи авто-тунелю
- Поліпшено синхронізацію плитки
- Додані ресурси для інтерфейсу Android TV
- Доданий відбиток APK
@@ -0,0 +1,5 @@
Що нового:
- Виправлена регресія із зупинкою тунелю
- Додано обфускацію лог-файлів
- Приховування плаваючої кнопки під час прокручування екрану
- Додано переклад на турецьку
@@ -0,0 +1,3 @@
Що нового:
- Додавання профілів Amnezia пліч-о-пліч з WireGuard
- Виправлена помилка з ярликами програми
@@ -0,0 +1,6 @@
Що нового:
- Офіційна підтримка AmneziaWG
- Імпорт/експорт конфігурації AmneziaWG
- Одноразове перемикання авто-тунелю при зміні стану мережі
- Підтримка нових мов
- Інші виправлення та покращення
@@ -0,0 +1,5 @@
Що нового:
- Покращено призначення імен під час імпорту тунелів
- Виправлено помилку початкового стану авто-тунелю
- Поліпшено обробку помилок
- Виправлена помилка імпорту Amnezia з .zip
@@ -0,0 +1,5 @@
Що нового:
- Додані нові переклади
- Виправлено роботу авто-тунелю на мобільному інтернеті
- Виправлено проблему з плаваючою кнопкою на Android TV
- Інші оптимізації та покращення

Some files were not shown because too many files have changed in this diff Show More