mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e56b950a6d | |||
| cd623c0c0c | |||
| 212c6cf088 | |||
| ca47127bff | |||
| e63733286c | |||
| 36c76565f7 | |||
| 47f8de8c57 | |||
| 5740012101 | |||
| 6f5bb24cfa | |||
| 5f791ffda1 | |||
| ec244eeda3 | |||
| ff2a2cc082 | |||
| a873546e9e | |||
| 757669ddbe | |||
| c71c4e5b29 | |||
| 7f0fea3766 | |||
| 53c19762ef | |||
| c98fa04f73 | |||
| aba0f7d4d3 | |||
| fa517b2124 | |||
| d7e2648393 |
@@ -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/)
|
||||
[](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
|
||||
[](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
|
||||
[](https://github.com/zaneschepke/fdroid)
|
||||
[](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">
|
||||
|
||||
[](https://discord.gg/rbRRNh6H7V)
|
||||
[](https://t.me/wgtunnel)
|
||||
|
||||
[<img src="https://img.shields.io/badge/Telegram-26A5E4.svg?style=for-the-badge&logo=Telegram&logoColor=white">](https://t.me/wgtunnel)
|
||||
[<img src="https://img.shields.io/badge/Matrix-000000.svg?style=for-the-badge&logo=Matrix&logoColor=white">](https://matrix.to/#/#wg-tunnel-space:matrix.org)
|
||||
</div>
|
||||
|
||||
<details open="open">
|
||||
@@ -49,7 +49,7 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/)
|
||||
<div style="text-align: left;">
|
||||
|
||||
## About
|
||||
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard®](https://www.wireguard.com/)
|
||||
Inspired by the official [wireguard-android](https://github.com/WireGuard/wireguard-android) app, WG Tunnel was created to address features and support missing from the official app. This app combines support for both [WireGuard](https://www.wireguard.com/)
|
||||
and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its primary feature of auto-tunneling (on-demand tunneling).
|
||||
|
||||
</div>
|
||||
@@ -61,14 +61,14 @@ and [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/), with its pr
|
||||
Thank you to the following:
|
||||
|
||||
- All of the users that have helped contribute to the project with ideas, translations, feedback, bug reports, testing, and donations.
|
||||
- [WireGuard®](https://www.wireguard.com/) - © Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
|
||||
- [WireGuard](https://www.wireguard.com/) - Jason A. Donenfeld (https://github.com/WireGuard/wireguard-android)
|
||||
|
||||
- [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) - Amnezia Team (https://github.com/amnezia-vpn/amneziawg-android)
|
||||
|
||||
## Screenshots
|
||||
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 10px;">
|
||||
<div style="display: flex; flex-wrap: wrap; justify-content: left; gap: 10px;">
|
||||
<img label="Main" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
|
||||
<img label="Settings" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
|
||||
<img label="Auto" src="fastlane/metadata/android/en-US/images/phoneScreenshots/auto_screen.png" width="200" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -10,9 +12,6 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -32,13 +31,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.wireguardautotunnel.core.shortcut.ShortcutManager
|
||||
import com.zaneschepke.networkmonitor.NetworkMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
@@ -46,15 +46,15 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
|
||||
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.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.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.TunnelAutoTunnelScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AdvancedScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.DisplayScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.KillSwitchScreen
|
||||
@@ -62,12 +62,14 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LanguageScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LocationDisclosureScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.LogsScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.AdvancedScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -81,7 +83,9 @@ class MainActivity : AppCompatActivity() {
|
||||
lateinit var tunnelManager: TunnelManager
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: ShortcutManager
|
||||
lateinit var networkMonitor: NetworkMonitor
|
||||
|
||||
private var lastLocationPermissionState: Boolean? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge(
|
||||
@@ -97,32 +101,41 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
installSplashScreen().apply {
|
||||
setKeepOnScreenCondition {
|
||||
!viewModel.isAppReady.value
|
||||
!viewModel.appState.value.isAppReady
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val configurationChange by viewModel.configurationChange.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
val appState by viewModel.appState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(configurationChange) {
|
||||
if (configurationChange) {
|
||||
Intent(this@MainActivity, MainActivity::class.java).also {
|
||||
startActivity(it)
|
||||
exitProcess(0)
|
||||
if (!appState.isAppReady) {
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
return@setContent
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
val snackbarController = SnackbarController.current
|
||||
|
||||
with(appState) {
|
||||
LaunchedEffect(isConfigChanged) {
|
||||
if (isConfigChanged) {
|
||||
Intent(this@MainActivity, MainActivity::class.java).also {
|
||||
startActivity(it)
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getEmitSplitTunnelApps(this@MainActivity)
|
||||
}
|
||||
|
||||
with(appUiState.appSettings) {
|
||||
LaunchedEffect(isShortcutsEnabled) {
|
||||
if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts()
|
||||
shortcutManager.addShortcuts()
|
||||
LaunchedEffect(errorMessage) {
|
||||
errorMessage?.let {
|
||||
snackbarController.showMessage(it.asString(this@MainActivity))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(popBackStack) {
|
||||
if (popBackStack) {
|
||||
navController.popBackStack()
|
||||
viewModel.handleEvent(AppEvent.BackStackPopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,28 +182,19 @@ class MainActivity : AppCompatActivity() {
|
||||
Box(modifier = Modifier.Companion.fillMaxSize().padding(padding)) {
|
||||
NavHost(
|
||||
navController,
|
||||
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
|
||||
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
|
||||
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
|
||||
) {
|
||||
composable<Route.Main> {
|
||||
MainScreen(
|
||||
uiState = appUiState,
|
||||
)
|
||||
MainScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.Settings> {
|
||||
SettingsScreen(
|
||||
appViewModel = viewModel,
|
||||
uiState = appUiState,
|
||||
)
|
||||
SettingsScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.LocationDisclosure> {
|
||||
LocationDisclosureScreen(viewModel, appUiState)
|
||||
LocationDisclosureScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.AutoTunnel> {
|
||||
AutoTunnelScreen(
|
||||
appUiState.appSettings,
|
||||
)
|
||||
AutoTunnelScreen(appUiState.appSettings, viewModel)
|
||||
}
|
||||
composable<Route.Appearance> {
|
||||
AppearanceScreen()
|
||||
@@ -199,48 +203,44 @@ class MainActivity : AppCompatActivity() {
|
||||
LanguageScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.Display> {
|
||||
DisplayScreen(appUiState)
|
||||
DisplayScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.Support> {
|
||||
SupportScreen(appUiState, viewModel)
|
||||
}
|
||||
composable<Route.AutoTunnelAdvanced> {
|
||||
AdvancedScreen(appUiState.appSettings, viewModel)
|
||||
AdvancedScreen(appUiState)
|
||||
}
|
||||
composable<Route.Logs> {
|
||||
LogsScreen()
|
||||
}
|
||||
composable<Route.Config> { backStack ->
|
||||
val args = backStack.toRoute<Route.Config>()
|
||||
val config =
|
||||
appUiState.tunnels.firstOrNull { it.id == args.id }
|
||||
ConfigScreen(config, viewModel)
|
||||
val config = appUiState.tunnels.firstOrNull { it.id == args.id }
|
||||
ConfigScreen(config)
|
||||
}
|
||||
composable<Route.TunnelOptions> { backStack ->
|
||||
val args = backStack.toRoute<Route.TunnelOptions>()
|
||||
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
|
||||
OptionsScreen(config, appUiState)
|
||||
OptionsScreen(config, appUiState, viewModel)
|
||||
}
|
||||
}
|
||||
composable<Route.Lock> {
|
||||
PinLockScreen(viewModel)
|
||||
}
|
||||
composable<Route.Scanner> {
|
||||
ScannerScreen()
|
||||
ScannerScreen(viewModel)
|
||||
}
|
||||
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>()
|
||||
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
|
||||
TunnelAutoTunnelScreen(it, appUiState.appSettings)
|
||||
TunnelAutoTunnelScreen(it, appUiState.appSettings, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,4 +256,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
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.TunnelRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
+1
-1
@@ -4,9 +4,9 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
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.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
+3
-3
@@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -51,7 +51,7 @@ class RestartReceiver : BroadcastReceiver() {
|
||||
if (settings.isRestoreOnBootEnabled) {
|
||||
if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) {
|
||||
Timber.d("Starting auto-tunnel on boot/update")
|
||||
serviceManager.startAutoTunnel(true)
|
||||
serviceManager.startAutoTunnel()
|
||||
} else {
|
||||
Timber.d("Restoring previous tunnel state")
|
||||
tunnelManager.restorePreviousState()
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
|
||||
interface NotificationManager {
|
||||
|
||||
+1
-1
@@ -12,9 +12,9 @@ import android.graphics.Color
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.zaneschepke.wireguardautotunnel.MainActivity
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.core.broadcast.NotificationActionReceiver
|
||||
import com.zaneschepke.wireguardautotunnel.MainActivity
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager.Companion.EXTRA_ID
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
|
||||
+25
-42
@@ -3,9 +3,8 @@ package com.zaneschepke.wireguardautotunnel.core.service
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
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 +19,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 +34,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 {
|
||||
@@ -50,7 +46,7 @@ class ServiceManager @Inject constructor(
|
||||
}.onFailure { Timber.e(it) }
|
||||
}
|
||||
|
||||
fun startAutoTunnel(background: Boolean) {
|
||||
fun startAutoTunnel() {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
val settings = appDataRepository.settings.get()
|
||||
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
|
||||
@@ -60,25 +56,22 @@ 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()
|
||||
startService(AutoTunnelService::class.java, !WireGuardAutoTunnel.isForeground())
|
||||
_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 {
|
||||
backgroundService = CompletableDeferred()
|
||||
startService(TunnelForegroundService::class.java, true)
|
||||
startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground())
|
||||
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { backgroundService.await() }
|
||||
?: throw IllegalStateException("Background service start timed out")
|
||||
service.start(tunnelConf)
|
||||
@@ -88,7 +81,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 {
|
||||
@@ -101,40 +106,18 @@ class ServiceManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAutoTunnel(background: Boolean) {
|
||||
fun toggleAutoTunnel() {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel(background)
|
||||
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
+112
-2
@@ -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,13 @@ class TunnelForegroundService : LifecycleService() {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STATS_DELAY = 1_000L
|
||||
const val PING_START_DELAY = 30_000L
|
||||
// ipv6 disabled or block on network
|
||||
// const val userspaceStartFailed = "Failed to send handshake initiation: write udp [::]"
|
||||
// const val ipv6Fails = "Failed to send data packets: write udp [::]"
|
||||
// const val ipv4Fails = "Failed to send data packets: write udp 0.0.0.0:51820"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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) }
|
||||
}
|
||||
|
||||
+28
-21
@@ -4,62 +4,66 @@ import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
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 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()
|
||||
} else {
|
||||
serviceManager.startAutoTunnel(true)
|
||||
serviceManager.startAutoTunnel()
|
||||
setActive()
|
||||
}
|
||||
}
|
||||
@@ -97,4 +101,7 @@ class AutoTunnelControlTile : TileService() {
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = lifecycleRegistry
|
||||
}
|
||||
|
||||
+24
-17
@@ -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
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,12 +2,12 @@ package com.zaneschepke.wireguardautotunnel.core.shortcut
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
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 com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -53,7 +53,7 @@ class ShortcutsActivity : ComponentActivity() {
|
||||
}
|
||||
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
|
||||
when (intent.action) {
|
||||
Action.START.name -> serviceManager.startAutoTunnel(true)
|
||||
Action.START.name -> serviceManager.startAutoTunnel()
|
||||
Action.STOP.name -> serviceManager.stopAutoTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
saveTunnelActiveState(tunnelConf, true)
|
||||
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)
|
||||
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
|
||||
saveTunnelActiveState(tunnelConf, false)
|
||||
stopBackend(tunnel)
|
||||
removeActiveTunnel(tunnel)
|
||||
// use latest tunnel
|
||||
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>>
|
||||
}
|
||||
|
||||
+15
-43
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class ServiceWorker @AssistedInject constructor(
|
||||
override suspend fun doWork(): Result = withContext(ioDispatcher) {
|
||||
Timber.i("Service worker started")
|
||||
with(appDataRepository.settings.get()) {
|
||||
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel(true)
|
||||
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel()
|
||||
if (tunnelManager.activeTunnels.value.isEmpty()) tunnelManager.restorePreviousState()
|
||||
}
|
||||
Result.success()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class DatabaseListConverters {
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.repository
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class AppDataRoomRepository
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.repository
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.repository
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.model.Settings
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
+7
-1
@@ -1,10 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.repository
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -30,6 +30,12 @@ class RoomTunnelRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveAll(tunnelConfs: List<TunnelConf>) {
|
||||
withContext(ioDispatcher) {
|
||||
tunnelConfigDao.saveAll(tunnelConfs.map(TunnelConfig::from))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?) {
|
||||
withContext(ioDispatcher) {
|
||||
tunnelConfigDao.resetPrimaryTunnel()
|
||||
|
||||
@@ -4,17 +4,17 @@ import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
|
||||
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
|
||||
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
|
||||
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
|
||||
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
|
||||
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
@@ -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,18 @@
|
||||
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.defaultName
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Transient
|
||||
import org.amnezia.awg.backend.Tunnel
|
||||
import timber.log.Timber
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
data class TunnelConf(
|
||||
@@ -29,12 +32,59 @@ data class TunnelConf(
|
||||
val isIpv4Preferred: Boolean = true,
|
||||
@Transient
|
||||
private var stateChangeCallback: ((Any) -> Unit)? = null,
|
||||
) : Tunnel, com.wireguard.android.backend.Tunnel {
|
||||
@Transient
|
||||
private var tunnelStatsCallback: (() -> Unit)? = null,
|
||||
@Transient
|
||||
private var bounceTunnelCallback: ((TunnelConf) -> Unit)? = null,
|
||||
) : Tunnel, org.amnezia.awg.backend.Tunnel {
|
||||
|
||||
fun setStateChangeCallback(callback: (Any) -> Unit) {
|
||||
stateChangeCallback = callback
|
||||
}
|
||||
|
||||
fun setTunnelStatsCallback(callback: (() -> Unit)) {
|
||||
tunnelStatsCallback = callback
|
||||
}
|
||||
|
||||
fun setBounceTunnelCallback(callback: (TunnelConf) -> Unit) {
|
||||
bounceTunnelCallback = callback
|
||||
}
|
||||
|
||||
fun copyWithCallback(
|
||||
id: Int = this.id,
|
||||
tunName: String = this.tunName,
|
||||
wgQuick: String = this.wgQuick,
|
||||
tunnelNetworks: List<String> = this.tunnelNetworks,
|
||||
isMobileDataTunnel: Boolean = this.isMobileDataTunnel,
|
||||
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
|
||||
amQuick: String = this.amQuick,
|
||||
isActive: Boolean = this.isActive,
|
||||
isPingEnabled: Boolean = this.isPingEnabled,
|
||||
pingInterval: Long? = this.pingInterval,
|
||||
pingCooldown: Long? = this.pingCooldown,
|
||||
pingIp: String? = this.pingIp,
|
||||
isEthernetTunnel: Boolean = this.isEthernetTunnel,
|
||||
isIpv4Preferred: Boolean = this.isIpv4Preferred,
|
||||
): TunnelConf {
|
||||
return TunnelConf(
|
||||
id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel,
|
||||
amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp,
|
||||
isEthernetTunnel, isIpv4Preferred,
|
||||
).apply {
|
||||
stateChangeCallback = this@TunnelConf.stateChangeCallback
|
||||
tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
|
||||
bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
|
||||
}
|
||||
}
|
||||
|
||||
fun onUpdateStatistics() {
|
||||
tunnelStatsCallback?.invoke()
|
||||
}
|
||||
|
||||
fun bounceTunnel(tunnelConf: TunnelConf) {
|
||||
bounceTunnelCallback?.invoke(tunnelConf)
|
||||
}
|
||||
|
||||
fun toAmConfig(): org.amnezia.awg.config.Config {
|
||||
return configFromAmQuick(amQuick.ifBlank { wgQuick })
|
||||
}
|
||||
@@ -43,15 +93,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 +105,23 @@ data class TunnelConf(
|
||||
stateChangeCallback?.invoke(newState)
|
||||
}
|
||||
|
||||
fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean {
|
||||
return updatedConf.wgQuick == wgQuick ||
|
||||
updatedConf.amQuick == amQuick
|
||||
fun isTunnelConfigChanged(updatedConf: TunnelConf): Boolean {
|
||||
return updatedConf.wgQuick != wgQuick || updatedConf.amQuick != amQuick || updatedConf.name != name
|
||||
}
|
||||
|
||||
fun isPingConfigMatching(updatedConf: TunnelConf): Boolean {
|
||||
return updatedConf.isPingEnabled == isPingEnabled &&
|
||||
pingIp == updatedConf.pingIp &&
|
||||
updatedConf.pingCooldown == pingCooldown &&
|
||||
updatedConf.pingInterval == pingInterval
|
||||
fun generateUniqueName(tunnelNames: List<String>): String {
|
||||
var tunnelName = this.tunName
|
||||
var num = 1
|
||||
while (tunnelNames.any { it == tunnelName }) {
|
||||
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
|
||||
"$name($num)"
|
||||
} else {
|
||||
val pair = tunnelName.extractNameAndNumber()
|
||||
"${pair?.first}($num)"
|
||||
}
|
||||
num++
|
||||
}
|
||||
return tunnelName
|
||||
}
|
||||
|
||||
suspend fun isTunnelPingable(context: CoroutineContext): Boolean {
|
||||
@@ -76,34 +129,37 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String): TunnelConf {
|
||||
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String? = null): TunnelConf {
|
||||
val amQuick = config.toAwgQuickString(true)
|
||||
val wgQuick = config.toWgQuickString()
|
||||
return TunnelConf(tunName = name, wgQuick = wgQuick, amQuick = amQuick)
|
||||
return TunnelConf(tunName = name ?: config.defaultName(), wgQuick = wgQuick, amQuick = amQuick)
|
||||
}
|
||||
|
||||
private const val IPV6_ALL_NETWORKS = "::/0"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ interface TunnelRepository {
|
||||
|
||||
suspend fun save(tunnelConf: TunnelConf)
|
||||
|
||||
suspend fun saveAll(tunnelConfList: List<TunnelConf>)
|
||||
|
||||
suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?)
|
||||
|
||||
suspend fun updateMobileDataTunnel(tunnelConf: TunnelConf?)
|
||||
|
||||
+16
-13
@@ -1,13 +1,16 @@
|
||||
package com.zaneschepke.wireguardautotunnel.domain.state
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
|
||||
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.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
|
||||
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,60 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.util.extensions.scaledHeight
|
||||
|
||||
@Composable
|
||||
fun <T> DropdownSelector(
|
||||
currentValue: T,
|
||||
options: List<T>,
|
||||
onValueSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
isExpanded: Boolean = false,
|
||||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (label != null) label()
|
||||
Text(
|
||||
text = currentValue.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
|
||||
}
|
||||
DropdownMenu(
|
||||
modifier = modifier.height(250.dp.scaledHeight()),
|
||||
scrollState = rememberScrollState(),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = option.toString()) },
|
||||
onClick = {
|
||||
onValueSelected(option)
|
||||
onDismiss() // Close dropdown after selection
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.button
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.size
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun SelectionItemLabel(
|
||||
textResId: Int,
|
||||
style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
isDescription: Boolean = false,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(textResId),
|
||||
style = style.copy(
|
||||
color = if (isDescription) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
-808
@@ -1,808 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.enums.InterfaceActions
|
||||
import com.zaneschepke.wireguardautotunnel.ui.enums.PeerActions
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import org.amnezia.awg.crypto.KeyPair
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val snackbar = SnackbarController.current
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
var isInterfaceDropDownExpanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val popBackStack by appViewModel.popBackStack.collectAsStateWithLifecycle(false)
|
||||
|
||||
val configPair = Pair(tunnelConf?.tunName ?: "", tunnelConf?.toAmConfig())
|
||||
|
||||
var tunnelName by remember {
|
||||
mutableStateOf(configPair.first)
|
||||
}
|
||||
|
||||
var interfaceState by remember {
|
||||
mutableStateOf(configPair.second?.let { InterfaceProxy.from(it.`interface`) } ?: InterfaceProxy())
|
||||
}
|
||||
|
||||
var showAmneziaValues by remember {
|
||||
mutableStateOf(configPair.second?.`interface`?.junkPacketCount?.isPresent == true)
|
||||
}
|
||||
|
||||
var showScripts by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val peersState = remember {
|
||||
(configPair.second?.peers?.map { PeerProxy.from(it) } ?: listOf(PeerProxy())).toMutableStateList()
|
||||
}
|
||||
|
||||
var showAuthPrompt by remember { mutableStateOf(false) }
|
||||
var isAuthenticated by remember { mutableStateOf(false) }
|
||||
|
||||
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
|
||||
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
|
||||
|
||||
if (showAuthPrompt) {
|
||||
AuthorizationPrompt(
|
||||
onSuccess = {
|
||||
showAuthPrompt = false
|
||||
isAuthenticated = true
|
||||
},
|
||||
onError = {
|
||||
showAuthPrompt = false
|
||||
snackbar.showMessage(
|
||||
context.getString(R.string.error_authentication_failed),
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
showAuthPrompt = false
|
||||
snackbar.showMessage(
|
||||
context.getString(R.string.error_authorization_failed),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(popBackStack) {
|
||||
if (popBackStack) navController.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopNavBar(stringResource(R.string.edit_tunnel), trailing = {
|
||||
IconButton(onClick = {
|
||||
tunnelConf?.let {
|
||||
appViewModel.updateExistingTunnelConfig(
|
||||
it,
|
||||
tunnelName,
|
||||
peersState,
|
||||
interfaceState,
|
||||
)
|
||||
} ?: appViewModel.saveNewTunnel(tunnelName, peersState, interfaceState)
|
||||
}) {
|
||||
val icon = Icons.Outlined.Save
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 24.dp.scaledHeight())
|
||||
.padding(horizontal = 24.dp.scaledWidth()),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
|
||||
modifier = Modifier
|
||||
.padding(16.dp.scaledWidth())
|
||||
.focusGroup(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
GroupLabel(
|
||||
stringResource(R.string.interface_),
|
||||
)
|
||||
Column {
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
onClick = {
|
||||
isInterfaceDropDownExpanded = true
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.MoreVert
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
DropdownMenu(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
expanded = isInterfaceDropDownExpanded,
|
||||
modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface),
|
||||
onDismissRequest = {
|
||||
isInterfaceDropDownExpanded = false
|
||||
},
|
||||
) {
|
||||
val isAmneziaCompatibilitySet = interfaceState.isAmneziaCompatibilityModeSet()
|
||||
InterfaceActions.entries.forEach { action ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = when (action) {
|
||||
InterfaceActions.TOGGLE_SHOW_SCRIPTS -> if (showScripts) {
|
||||
stringResource(R.string.hide_scripts)
|
||||
} else {
|
||||
stringResource(R.string.show_scripts)
|
||||
}
|
||||
InterfaceActions.TOGGLE_AMNEZIA_VALUES -> if (showAmneziaValues) {
|
||||
stringResource(R.string.hide_amnezia_properties)
|
||||
} else {
|
||||
stringResource(R.string.show_amnezia_properties)
|
||||
}
|
||||
|
||||
InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) {
|
||||
stringResource(R.string.remove_amnezia_compatibility)
|
||||
} else {
|
||||
stringResource(R.string.enable_amnezia_compatibility)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
isInterfaceDropDownExpanded = false
|
||||
when (action) {
|
||||
InterfaceActions.TOGGLE_AMNEZIA_VALUES -> showAmneziaValues = !showAmneziaValues
|
||||
InterfaceActions.TOGGLE_SHOW_SCRIPTS -> showScripts = !showScripts
|
||||
InterfaceActions.SET_AMNEZIA_COMPATIBILITY -> if (isAmneziaCompatibilitySet) {
|
||||
showAmneziaValues = false
|
||||
interfaceState = interfaceState.resetAmneziaProperties()
|
||||
} else {
|
||||
showAmneziaValues = true
|
||||
interfaceState = interfaceState.toAmneziaCompatibilityConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfigurationTextBox(
|
||||
value = tunnelName,
|
||||
onValueChange = { tunnelName = it },
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.name),
|
||||
hint = stringResource(R.string.tunnel_name).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
val privateKeyEnabled = (tunnelConf == null) || isAuthenticated
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showAuthPrompt = true },
|
||||
value = interfaceState.privateKey,
|
||||
visualTransformation =
|
||||
if (privateKeyEnabled) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
enabled = privateKeyEnabled,
|
||||
onValueChange = { interfaceState = interfaceState.copy(privateKey = it) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
enabled = privateKeyEnabled,
|
||||
modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize),
|
||||
onClick = {
|
||||
val keypair = KeyPair()
|
||||
interfaceState = interfaceState.copy(
|
||||
privateKey = keypair.privateKey.toBase64(),
|
||||
publicKey = keypair.publicKey.toBase64(),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
stringResource(R.string.rotate_keys),
|
||||
tint = if (privateKeyEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
stringResource(R.string.private_key),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.base64_key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(FocusRequester.Default),
|
||||
value = interfaceState.publicKey,
|
||||
enabled = false,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(publicKey = it)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.focusRequester(FocusRequester.Default).size(iconSize),
|
||||
onClick = {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(interfaceState.publicKey),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.ContentCopy,
|
||||
stringResource(R.string.copy_public_key),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
stringResource(R.string.public_key),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.base64_key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.addresses,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(addresses = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.addresses),
|
||||
hint = stringResource(R.string.comma_separated_list),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 5.dp),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.listenPort,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(listenPort = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.listen_port),
|
||||
hint = stringResource(R.string.random),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.dnsServers,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(dnsServers = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.dns_servers),
|
||||
hint = stringResource(R.string.comma_separated_list),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(3 / 5f)
|
||||
.padding(end = 5.dp),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.mtu,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(mtu = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.mtu),
|
||||
hint = stringResource(R.string.auto),
|
||||
modifier = Modifier.width(IntrinsicSize.Min),
|
||||
)
|
||||
}
|
||||
if (showScripts) {
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.preUp,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(preUp = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.pre_up),
|
||||
hint = stringResource(R.string.comma_separated_list).lowercase(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.postUp,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(postUp = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.post_up),
|
||||
hint = stringResource(R.string.comma_separated_list).lowercase(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.preDown,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(preDown = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.pre_down),
|
||||
hint = stringResource(R.string.comma_separated_list).lowercase(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.postDown,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(postDown = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.post_down),
|
||||
hint = stringResource(R.string.comma_separated_list).lowercase(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (showAmneziaValues) {
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.junkPacketCount,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(junkPacketCount = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.junk_packet_count),
|
||||
hint = stringResource(R.string.junk_packet_count).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.junkPacketMinSize,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(junkPacketMinSize = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.junk_packet_minimum_size),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.junk_packet_minimum_size,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.junkPacketMaxSize,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(junkPacketMaxSize = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.junk_packet_maximum_size),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.junk_packet_maximum_size,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.initPacketJunkSize,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(initPacketJunkSize = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.init_packet_junk_size),
|
||||
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.responsePacketJunkSize,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(responsePacketJunkSize = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.response_packet_junk_size),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.response_packet_junk_size,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.initPacketMagicHeader,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(initPacketMagicHeader = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.init_packet_magic_header),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.init_packet_magic_header,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.responsePacketMagicHeader,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(responsePacketMagicHeader = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.response_packet_magic_header),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.response_packet_magic_header,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.underloadPacketMagicHeader,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(underloadPacketMagicHeader = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.underload_packet_magic_header),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.underload_packet_magic_header,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = interfaceState.transportPacketMagicHeader,
|
||||
onValueChange = {
|
||||
interfaceState = interfaceState.copy(transportPacketMagicHeader = it)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.transport_packet_magic_header),
|
||||
hint =
|
||||
stringResource(
|
||||
R.string.transport_packet_magic_header,
|
||||
).lowercase(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
peersState.forEachIndexed { index, peer ->
|
||||
var isPeerDropDownExpanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val isLanExcluded = peer.isLanExcluded()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Top),
|
||||
modifier = Modifier
|
||||
.padding(16.dp.scaledWidth())
|
||||
.focusGroup(),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(),
|
||||
) {
|
||||
GroupLabel(
|
||||
stringResource(R.string.peer),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
onClick = {
|
||||
// TODO make a dialog to confirm this
|
||||
peersState.removeAt(index)
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.Delete
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
Column {
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
onClick = {
|
||||
isPeerDropDownExpanded = true
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.MoreVert
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
DropdownMenu(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
expanded = isPeerDropDownExpanded,
|
||||
modifier = Modifier.shadow(12.dp).background(MaterialTheme.colorScheme.surface),
|
||||
onDismissRequest = {
|
||||
isPeerDropDownExpanded = false
|
||||
},
|
||||
) {
|
||||
PeerActions.entries.forEach { action ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = when (action) {
|
||||
PeerActions.EXCLUDE_LAN -> if (isLanExcluded) {
|
||||
stringResource(R.string.include_lan)
|
||||
} else {
|
||||
stringResource(R.string.exclude_lan)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
isPeerDropDownExpanded = false
|
||||
when (action) {
|
||||
PeerActions.EXCLUDE_LAN -> if (isLanExcluded) {
|
||||
peersState[index] = peer.includeLan()
|
||||
} else {
|
||||
peersState[index] = peer.excludeLan()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConfigurationTextBox(
|
||||
value = peer.publicKey,
|
||||
onValueChange = { value ->
|
||||
peersState[index] = peersState[index].copy(publicKey = value)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.public_key),
|
||||
hint = stringResource(R.string.base64_key),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val presharedKeyEnabled = (tunnelConf == null) || isAuthenticated ||
|
||||
with(configPair.second?.peers?.getOrNull(index)?.preSharedKey) { this?.isEmpty == true || this?.isPresent == false }
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showAuthPrompt = true },
|
||||
value = peer.preSharedKey,
|
||||
visualTransformation =
|
||||
if (presharedKeyEnabled) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
enabled = presharedKeyEnabled,
|
||||
onValueChange = { value -> peersState[index] = peersState[index].copy(preSharedKey = value) },
|
||||
label = {
|
||||
Text(
|
||||
stringResource(R.string.preshared_key),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.optional),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = peer.persistentKeepalive,
|
||||
enabled = true,
|
||||
onValueChange = { value ->
|
||||
peersState[index] = peersState[index].copy(persistentKeepalive = value)
|
||||
},
|
||||
trailingIcon = {
|
||||
Text(
|
||||
stringResource(R.string.seconds),
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.persistent_keepalive), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium)
|
||||
},
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.optional_no_recommend), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline)
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = peer.endpoint,
|
||||
onValueChange = { value ->
|
||||
peersState[index] = peersState[index].copy(endpoint = value)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.endpoint),
|
||||
hint = stringResource(R.string.endpoint).lowercase(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = peer.allowedIps,
|
||||
enabled = true,
|
||||
onValueChange = { value ->
|
||||
peersState[index] = peersState[index].copy(allowedIps = value)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
stringResource(R.string.allowed_ips),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline)
|
||||
},
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 140.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
peersState.add(PeerProxy())
|
||||
}) {
|
||||
Text(stringResource(R.string.add_peer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-157
@@ -2,23 +2,9 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.overscroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -27,45 +13,34 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.domain.state.TunnelState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AddTunnelFab
|
||||
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.TunnelList
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.MainViewModel
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) {
|
||||
fun MainScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
@@ -75,54 +50,35 @@ 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())
|
||||
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
|
||||
val sortedTunnels = remember(uiState.tunnels) {
|
||||
uiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
|
||||
val startAutoTunnel = withVpnPermission<Unit> { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
|
||||
val startTunnel = withVpnPermission<TunnelConf> { viewModel.handleEvent(AppEvent.StartTunnel(it)) }
|
||||
val autoTunnelToggleBattery = withIgnoreBatteryOpt(appUiState.generalState.isBatteryOptimizationDisableShown) {
|
||||
if (!appUiState.generalState.isBatteryOptimizationDisableShown) viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
|
||||
if (appUiState.appSettings.isKernelEnabled) viewModel.handleEvent(AppEvent.ToggleAutoTunnel) else startAutoTunnel.invoke(Unit)
|
||||
}
|
||||
|
||||
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
|
||||
val startTunnel = withVpnPermission<TunnelConf> {
|
||||
viewModel.onTunnelStart(it)
|
||||
}
|
||||
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
|
||||
onNoFileExplorer = { snackbar.showMessage(context.getString(R.string.error_no_file_explorer)) },
|
||||
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
|
||||
)
|
||||
|
||||
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
|
||||
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
|
||||
if (uiState.appSettings.isKernelEnabled) {
|
||||
viewModel.onToggleAutoTunnel()
|
||||
} else {
|
||||
startAutoTunnel.invoke(Unit)
|
||||
}
|
||||
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
|
||||
navController.navigate(Route.Scanner)
|
||||
}
|
||||
|
||||
val nestedScrollConnection = remember {
|
||||
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
|
||||
}
|
||||
|
||||
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
|
||||
snackbar.showMessage(
|
||||
context.getString(R.string.error_no_file_explorer),
|
||||
)
|
||||
}, onData = { data ->
|
||||
viewModel.onTunnelFileSelected(data, context)
|
||||
})
|
||||
|
||||
val requestPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
|
||||
navController.navigate(Route.Scanner)
|
||||
}
|
||||
|
||||
if (showDeleteTunnelAlertDialog) {
|
||||
if (showDeleteTunnelAlertDialog && selectedTunnel != null) {
|
||||
InfoDialog(
|
||||
onDismiss = { showDeleteTunnelAlertDialog = false },
|
||||
onAttest = {
|
||||
selectedTunnel?.let { viewModel.onDelete(it) }
|
||||
selectedTunnel?.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
|
||||
showDeleteTunnelAlertDialog = false
|
||||
selectedTunnel = null
|
||||
},
|
||||
@@ -132,52 +88,22 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
|
||||
)
|
||||
}
|
||||
|
||||
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConf) {
|
||||
if (!checked) return viewModel.onTunnelStop(tunnel).let { }
|
||||
if (uiState.appSettings.isKernelEnabled) viewModel.onTunnelStart(tunnel) else startTunnel(tunnel)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier =
|
||||
Modifier.pointerInput(Unit) {
|
||||
if (uiState.tunnels.isEmpty()) return@pointerInput
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
selectedTunnel = null
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
floatingActionButton = {
|
||||
if (!isRunningOnTv) {
|
||||
ScrollDismissFab({
|
||||
val icon = Icons.Filled.Add
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}, isVisible = isFabVisible, onClick = {
|
||||
showBottomSheet = true
|
||||
})
|
||||
AddTunnelFab(
|
||||
isVisible = isFabVisible,
|
||||
onClick = { showBottomSheet = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
if (isRunningOnTv) {
|
||||
TopNavBar(
|
||||
showBack = false,
|
||||
title = stringResource(R.string.app_name),
|
||||
trailing = {
|
||||
IconButton(onClick = {
|
||||
showBottomSheet = true
|
||||
}) {
|
||||
val icon = Icons.Outlined.Add
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
)
|
||||
}
|
||||
},
|
||||
AddTunnelFab(
|
||||
isVisible = isFabVisible,
|
||||
isTv = true,
|
||||
onClick = { showBottomSheet = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -187,60 +113,41 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
|
||||
onDismiss = { showBottomSheet = false },
|
||||
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
|
||||
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
|
||||
onClipboardClick = {
|
||||
clipboard.getText()?.text?.let {
|
||||
viewModel.onClipboardImport(it)
|
||||
}
|
||||
},
|
||||
onManualImportClick = {
|
||||
navController.navigate(
|
||||
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
|
||||
)
|
||||
},
|
||||
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } },
|
||||
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) },
|
||||
onUrlClick = { showUrlImportDialog = true },
|
||||
)
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize().padding(padding).padding(top = 24.dp.scaledHeight())
|
||||
.overscroll(ScrollableDefaults.overscrollEffect())
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = rememberLazyListState(0, uiState.tunnels.count()),
|
||||
userScrollEnabled = true,
|
||||
reverseLayout = false,
|
||||
flingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
) {
|
||||
if (uiState.tunnels.isEmpty()) {
|
||||
item {
|
||||
GettingStartedLabel(onClick = { context.openWebUrl(it) })
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
AutoTunnelRowItem(uiState) {
|
||||
autoTunnelToggleBattery.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
items(
|
||||
sortedTunnels,
|
||||
key = { tunnel -> tunnel.id },
|
||||
) { tunnel ->
|
||||
val expanded = uiState.generalState.isTunnelStatsExpanded
|
||||
val tunnelState = activeTunnels.getOrDefault(tunnel.id, TunnelState())
|
||||
TunnelRowItem(
|
||||
tunnelState.state.isUp(),
|
||||
expanded,
|
||||
selectedTunnel?.id == tunnel.id,
|
||||
tunnel,
|
||||
tunnelState = tunnelState,
|
||||
{ selectedTunnel = tunnel },
|
||||
{ viewModel.onExpandedChanged(!expanded) },
|
||||
onDelete = { showDeleteTunnelAlertDialog = true },
|
||||
onCopy = { viewModel.onCopyTunnel(tunnel) },
|
||||
onSwitchClick = { onTunnelToggle(it, tunnel) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showUrlImportDialog) {
|
||||
UrlImportDialog(
|
||||
onDismiss = { showUrlImportDialog = false },
|
||||
onConfirm = { url ->
|
||||
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
|
||||
showUrlImportDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
TunnelList(
|
||||
appUiState = appUiState,
|
||||
activeTunnels = appUiState.activeTunnels,
|
||||
selectedTunnel = selectedTunnel,
|
||||
onTunnelSelected = { selectedTunnel = it },
|
||||
onDeleteTunnel = {
|
||||
selectedTunnel = it
|
||||
showDeleteTunnelAlertDialog = true
|
||||
},
|
||||
onToggleAutoTunnel = { autoTunnelToggleBattery.invoke() },
|
||||
onToggleTunnel = { tunnel, checked ->
|
||||
if (checked) startTunnel(tunnel) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
|
||||
},
|
||||
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
|
||||
onCopyTunnel = { viewModel.handleEvent(AppEvent.CopyTunnel(it)) },
|
||||
nestedScrollConnection,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(top = 24.dp.scaledHeight()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -6,16 +6,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import xyz.teamgravity.pin_lock_compose.PinLock
|
||||
|
||||
@Composable
|
||||
fun PinLockScreen(appViewModel: AppViewModel) {
|
||||
fun PinLockScreen(viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val snackbar = SnackbarController.current
|
||||
@@ -57,7 +58,7 @@ fun PinLockScreen(appViewModel: AppViewModel) {
|
||||
snackbar.showMessage(
|
||||
StringValue.StringResource(R.string.pin_created).asString(context),
|
||||
)
|
||||
appViewModel.onPinLockEnabled()
|
||||
viewModel.handleEvent(AppEvent.TogglePinLock)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+4
-14
@@ -3,26 +3,16 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.journeyapps.barcodescanner.CompoundBarcodeView
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.ScannerViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
|
||||
fun ScannerScreen(viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
val success = viewModel.success.collectAsStateWithLifecycle(null)
|
||||
|
||||
LaunchedEffect(success.value) {
|
||||
if (success.value != null) navController.popBackStack()
|
||||
}
|
||||
|
||||
val barcodeView = remember {
|
||||
CompoundBarcodeView(context).apply {
|
||||
@@ -30,7 +20,7 @@ fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
|
||||
this.setStatusText("")
|
||||
this.decodeSingle { result ->
|
||||
result.text?.let { barCodeOrQr ->
|
||||
viewModel.onTunnelQrResult(barCodeOrQr)
|
||||
viewModel.handleEvent(AppEvent.ImportTunnelFromQrCode(barCodeOrQr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-269
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
@@ -40,10 +39,11 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.compon
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelAutoTunnelViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tunnelAutoTunnelViewModel: TunnelAutoTunnelViewModel = hiltViewModel()) {
|
||||
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
|
||||
var currentText by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(tunnelConf.tunnelNetworks) {
|
||||
@@ -86,10 +86,10 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
tunnelConf.isMobileDataTunnel,
|
||||
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
|
||||
)
|
||||
},
|
||||
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.Outlined.SettingsEthernet,
|
||||
@@ -108,10 +108,10 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
tunnelConf.isEthernetTunnel,
|
||||
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
|
||||
)
|
||||
},
|
||||
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -155,9 +155,9 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
|
||||
description = {
|
||||
TrustedNetworkTextBox(
|
||||
tunnelConf.tunnelNetworks,
|
||||
onDelete = { tunnelAutoTunnelViewModel.onDeleteRunSSID(it, tunnelConf) },
|
||||
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
|
||||
currentText = currentText,
|
||||
onSave = { tunnelAutoTunnelViewModel.onSaveRunSSID(it, tunnelConf) },
|
||||
onSave = { viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf)) },
|
||||
onValueChange = { currentText = it },
|
||||
supporting = {
|
||||
if (appSettings.isWildcardsEnabled) {
|
||||
|
||||
+18
-26
@@ -30,29 +30,27 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelOptionsViewModel
|
||||
import kotlin.text.isBlank
|
||||
import kotlin.text.isNullOrBlank
|
||||
import kotlin.text.toLong
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: TunnelOptionsViewModel = hiltViewModel()) {
|
||||
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
val navController = LocalNavController.current
|
||||
|
||||
var currentText by remember { mutableStateOf("") }
|
||||
@@ -61,10 +59,6 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
val onPingToggle = {
|
||||
viewModel.saveTunnel(tunnelConf.copy(isPingEnabled = !tunnelConf.isPingEnabled))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopNavBar(tunnelConf.tunName)
|
||||
@@ -101,10 +95,10 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
tunnelConf.isPrimaryTunnel,
|
||||
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.Outlined.Bolt,
|
||||
@@ -159,10 +153,10 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
tunnelConf.isIpv4Preferred,
|
||||
onClick = { viewModel.onToggleIpv4(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.onToggleIpv4(tunnelConf) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.AutoMirrored.Outlined.CallSplit,
|
||||
@@ -195,11 +189,11 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
checked = tunnelConf.isPingEnabled,
|
||||
enabled = !appUiState.activeTunnels.containsKey(tunnelConf.id),
|
||||
onClick = { onPingToggle() },
|
||||
enabled = !appUiState.activeTunnels.isUp(tunnelConf),
|
||||
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
|
||||
)
|
||||
},
|
||||
onClick = { onPingToggle() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
|
||||
),
|
||||
)
|
||||
if (tunnelConf.isPingEnabled) {
|
||||
@@ -211,11 +205,9 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
tunnelConf.pingIp,
|
||||
stringResource(R.string.set_custom_ping_ip),
|
||||
stringResource(R.string.default_ping_ip),
|
||||
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
|
||||
onSubmit = {
|
||||
viewModel.saveTunnel(
|
||||
tunnelConf.copy(pingIp = it.ifBlank { null }),
|
||||
)
|
||||
isErrorValue = { error -> !error.isNullOrBlank() && !error.isValidIpv4orIpv6Address() },
|
||||
onSubmit = { ip ->
|
||||
viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip))
|
||||
},
|
||||
)
|
||||
fun isSecondsError(seconds: String?): Boolean {
|
||||
@@ -230,8 +222,8 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
isErrorValue = ::isSecondsError,
|
||||
onSubmit = {
|
||||
viewModel.onPingIntervalChange(tunnelConf, it)
|
||||
onSubmit = { interval ->
|
||||
viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval))
|
||||
},
|
||||
)
|
||||
SubmitConfigurationTextBox(
|
||||
@@ -242,7 +234,7 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
isErrorValue = ::isSecondsError,
|
||||
onSubmit = { viewModel.onPingCoolDownChange(tunnelConf, it) },
|
||||
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
|
||||
@Composable
|
||||
fun AddTunnelFab(isVisible: Boolean = true, isTv: Boolean = false, onClick: () -> Unit) {
|
||||
if (isTv) {
|
||||
TopNavBar(
|
||||
showBack = false,
|
||||
title = stringResource(R.string.app_name),
|
||||
trailing = {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(Icons.Outlined.Add, stringResource(R.string.add_tunnel))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ScrollDismissFab(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
stringResource(R.string.add_tunnel),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
},
|
||||
isVisible = isVisible,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,9 +12,9 @@ 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.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
|
||||
|
||||
+23
@@ -8,6 +8,7 @@ import androidx.compose.material.icons.Icons
|
||||
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.Link
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -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
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.overscroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TunnelList(
|
||||
appUiState: AppUiState,
|
||||
activeTunnels: Map<TunnelConf, TunnelState>,
|
||||
selectedTunnel: TunnelConf?,
|
||||
onTunnelSelected: (TunnelConf) -> Unit,
|
||||
onDeleteTunnel: (TunnelConf) -> Unit,
|
||||
onToggleAutoTunnel: () -> Unit,
|
||||
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
|
||||
onExpandStats: () -> Unit,
|
||||
onCopyTunnel: (TunnelConf) -> Unit,
|
||||
nestedScrollConnection: NestedScrollConnection,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
val sortedTunnels = remember(appUiState.tunnels) {
|
||||
appUiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
if (appUiState.tunnels.isEmpty()) return@pointerInput
|
||||
}
|
||||
.overscroll(ScrollableDefaults.overscrollEffect())
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = rememberLazyListState(0, appUiState.tunnels.count()),
|
||||
userScrollEnabled = true,
|
||||
reverseLayout = false,
|
||||
flingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
) {
|
||||
if (appUiState.tunnels.isEmpty()) {
|
||||
item {
|
||||
GettingStartedLabel(onClick = { context.openWebUrl(it) })
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
AutoTunnelRowItem(appUiState, onToggleAutoTunnel)
|
||||
}
|
||||
}
|
||||
items(sortedTunnels, key = { it.id }) { tunnel ->
|
||||
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
|
||||
TunnelRowItem(
|
||||
isActive = tunnel.isActive,
|
||||
expanded = appUiState.generalState.isTunnelStatsExpanded,
|
||||
isSelected = selectedTunnel?.id == tunnel.id,
|
||||
tunnel = tunnel,
|
||||
tunnelState = tunnelState,
|
||||
onHold = { onTunnelSelected(tunnel) },
|
||||
onClick = onExpandStats,
|
||||
onCopy = { onCopyTunnel(tunnel) },
|
||||
onDelete = { onDeleteTunnel(tunnel) },
|
||||
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-105
@@ -46,25 +46,28 @@ fun TunnelRowItem(
|
||||
onClick: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSwitchClick: (checked: Boolean) -> Unit,
|
||||
onSwitchClick: (Boolean) -> Unit,
|
||||
) {
|
||||
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
|
||||
val context = LocalContext.current
|
||||
val snackbar = SnackbarController.current
|
||||
val navController = LocalNavController.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbar = SnackbarController.current
|
||||
val itemFocusRequester = remember { FocusRequester() }
|
||||
val isTv = context.isRunningOnTv()
|
||||
|
||||
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
|
||||
val leadingIcon = when {
|
||||
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
|
||||
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
|
||||
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
|
||||
else -> Icons.Rounded.Circle
|
||||
}
|
||||
|
||||
ExpandingRowListItem(
|
||||
leading = {
|
||||
val icon = when {
|
||||
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
|
||||
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
|
||||
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
|
||||
else -> Icons.Rounded.Circle
|
||||
}
|
||||
Icon(
|
||||
icon,
|
||||
icon.name,
|
||||
leadingIcon,
|
||||
leadingIcon.name,
|
||||
tint = leadingIconColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
@@ -75,10 +78,8 @@ fun TunnelRowItem(
|
||||
onHold()
|
||||
},
|
||||
onClick = {
|
||||
if (!context.isRunningOnTv()) {
|
||||
if (isActive) {
|
||||
onClick()
|
||||
}
|
||||
if (!isTv) {
|
||||
if (isActive) onClick()
|
||||
} else {
|
||||
onHold()
|
||||
itemFocusRequester.requestFocus()
|
||||
@@ -87,108 +88,56 @@ fun TunnelRowItem(
|
||||
isExpanded = expanded && isActive,
|
||||
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
|
||||
trailing = {
|
||||
if (
|
||||
isSelected &&
|
||||
!context.isRunningOnTv()
|
||||
) {
|
||||
if (isSelected && !isTv) {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
Route.TunnelOptions(tunnel.id),
|
||||
)
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.Settings
|
||||
Icon(
|
||||
icon,
|
||||
icon.name,
|
||||
)
|
||||
IconButton(onClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) }) {
|
||||
Icon(Icons.Rounded.Settings, "Settings")
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.focusable(),
|
||||
onClick = { onCopy() },
|
||||
) {
|
||||
val icon = Icons.Rounded.CopyAll
|
||||
Icon(icon, icon.name)
|
||||
IconButton(modifier = Modifier.focusable(), onClick = onCopy) {
|
||||
Icon(Icons.Rounded.CopyAll, "Copy")
|
||||
}
|
||||
IconButton(
|
||||
enabled = !isActive,
|
||||
modifier = Modifier.focusable(),
|
||||
onClick = { onDelete() },
|
||||
) {
|
||||
val icon = Icons.Rounded.Delete
|
||||
Icon(icon, icon.name)
|
||||
IconButton(modifier = Modifier.focusable(), enabled = !isActive, onClick = onDelete) {
|
||||
Icon(Icons.Rounded.Delete, "Delete")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.isRunningOnTv()) {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onHold()
|
||||
navController.navigate(
|
||||
Route.TunnelOptions(tunnel.id),
|
||||
)
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.Settings
|
||||
Icon(
|
||||
icon,
|
||||
icon.name,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onClick()
|
||||
} else {
|
||||
snackbar.showMessage(
|
||||
context.getString(R.string.turn_on_tunnel),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.Info
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { onCopy() },
|
||||
) {
|
||||
val icon = Icons.Rounded.CopyAll
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
snackbar.showMessage(
|
||||
context.getString(R.string.turn_off_tunnel),
|
||||
)
|
||||
} else {
|
||||
onHold()
|
||||
onDelete()
|
||||
}
|
||||
},
|
||||
) {
|
||||
val icon = Icons.Rounded.Delete
|
||||
Icon(
|
||||
icon,
|
||||
icon.name,
|
||||
)
|
||||
}
|
||||
ScaledSwitch(
|
||||
modifier = Modifier.focusRequester(itemFocusRequester),
|
||||
checked = isActive,
|
||||
onClick = onSwitchClick,
|
||||
)
|
||||
} else if (isTv) {
|
||||
Row {
|
||||
IconButton(onClick = {
|
||||
onHold()
|
||||
navController.navigate(Route.TunnelOptions(tunnel.id))
|
||||
}) {
|
||||
Icon(Icons.Rounded.Settings, "Settings")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (isActive) onClick() else snackbar.showMessage(context.getString(R.string.turn_on_tunnel))
|
||||
}) {
|
||||
Icon(Icons.Rounded.Info, "Info")
|
||||
}
|
||||
IconButton(onClick = onCopy) {
|
||||
Icon(Icons.Rounded.CopyAll, "Copy")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (isActive) {
|
||||
snackbar.showMessage(context.getString(R.string.turn_off_tunnel))
|
||||
} else {
|
||||
onHold()
|
||||
onDelete()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Rounded.Delete, "Delete")
|
||||
}
|
||||
} else {
|
||||
ScaledSwitch(
|
||||
modifier = Modifier.focusRequester(itemFocusRequester),
|
||||
checked = isActive,
|
||||
onClick = onSwitchClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ScaledSwitch(
|
||||
modifier = Modifier.focusRequester(itemFocusRequester),
|
||||
checked = isActive,
|
||||
onClick = onSwitchClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config
|
||||
|
||||
import android.view.WindowManager
|
||||
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.DisposableEffect
|
||||
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.MainActivity
|
||||
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()
|
||||
|
||||
val activity = context as? MainActivity
|
||||
|
||||
// Secure screen due to sensitive information
|
||||
DisposableEffect(Unit) {
|
||||
activity?.window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
)
|
||||
onDispose {
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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,
|
||||
)
|
||||
}
|
||||
+102
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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,
|
||||
)
|
||||
+60
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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)))
|
||||
}
|
||||
}
|
||||
+59
@@ -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() },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+77
@@ -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`) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
+11
@@ -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 = "",
|
||||
)
|
||||
+8
@@ -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>>
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.PauseCircle
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
|
||||
@Composable
|
||||
fun AdvancedScreen(appSettings: AppSettings, appViewModel: AppViewModel) {
|
||||
var isDropDownExpanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopNavBar(stringResource(R.string.advanced_settings))
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 24.dp.scaledHeight())
|
||||
.padding(horizontal = 24.dp.scaledWidth()),
|
||||
) {
|
||||
SurfaceSelectionGroupButton(
|
||||
listOf(
|
||||
SelectionItem(
|
||||
Icons.Outlined.PauseCircle,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.debounce_delay),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
isDropDownExpanded = true
|
||||
},
|
||||
trailing = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = appSettings.debounceDelaySeconds.toString(), style = MaterialTheme.typography.bodyMedium)
|
||||
val icon = Icons.Default.ArrowDropDown
|
||||
Icon(icon, icon.name)
|
||||
}
|
||||
DropdownMenu(
|
||||
modifier = Modifier.height(250.dp.scaledHeight()),
|
||||
scrollState = rememberScrollState(),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
expanded = isDropDownExpanded,
|
||||
onDismissRequest = {
|
||||
isDropDownExpanded = false
|
||||
},
|
||||
) {
|
||||
(0..10).forEachIndexed { index, num ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = num.toString())
|
||||
},
|
||||
onClick = {
|
||||
isDropDownExpanded = false
|
||||
appViewModel.saveAppSettings(
|
||||
appSettings.copy(debounceDelaySeconds = num),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,11 +19,11 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
|
||||
+8
-8
@@ -10,18 +10,18 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.DisplayViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltViewModel()) {
|
||||
fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopNavBar(stringResource(R.string.display_theme))
|
||||
@@ -40,23 +40,23 @@ fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltView
|
||||
IconSurfaceButton(
|
||||
title = stringResource(R.string.automatic),
|
||||
onClick = {
|
||||
viewModel.onThemeChange(Theme.AUTOMATIC)
|
||||
viewModel.handleEvent(AppEvent.SetTheme(Theme.AUTOMATIC))
|
||||
},
|
||||
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
|
||||
)
|
||||
IconSurfaceButton(
|
||||
title = stringResource(R.string.light),
|
||||
onClick = { viewModel.onThemeChange(Theme.LIGHT) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.LIGHT)) },
|
||||
selected = appUiState.generalState.theme == Theme.LIGHT,
|
||||
)
|
||||
IconSurfaceButton(
|
||||
title = stringResource(R.string.dark),
|
||||
onClick = { viewModel.onThemeChange(Theme.DARK) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DARK)) },
|
||||
selected = appUiState.generalState.theme == Theme.DARK,
|
||||
)
|
||||
IconSurfaceButton(
|
||||
title = stringResource(R.string.dynamic),
|
||||
onClick = { viewModel.onThemeChange(Theme.DYNAMIC) },
|
||||
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DYNAMIC)) },
|
||||
selected = appUiState.generalState.theme == Theme.DYNAMIC,
|
||||
)
|
||||
}
|
||||
|
||||
+10
-30
@@ -18,44 +18,24 @@ 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.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
|
||||
fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val toggleVpnSwitch = withVpnPermission<Boolean> { appViewModel.onToggleVpnKillSwitch(it) }
|
||||
|
||||
fun toggleVpnKillSwitch() {
|
||||
with(uiState.appSettings) {
|
||||
// TODO improve this error message
|
||||
if (isKernelEnabled) return SnackbarController.showMessage(StringValue.StringResource(R.string.kernel_not_supported))
|
||||
if (isVpnKillSwitchEnabled) {
|
||||
appViewModel.onToggleVpnKillSwitch(false)
|
||||
} else {
|
||||
toggleVpnSwitch.invoke(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLanOnKillSwitch() {
|
||||
with(uiState.appSettings) {
|
||||
appViewModel.onToggleLanOnKillSwitch(!isLanOnKillSwitchEnabled)
|
||||
}
|
||||
}
|
||||
val toggleVpnSwitch = withVpnPermission<Boolean> { viewModel.handleEvent(AppEvent.ToggleVpnKillSwitch) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -102,13 +82,13 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
toggleVpnKillSwitch()
|
||||
toggleVpnSwitch.invoke(true)
|
||||
},
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
uiState.appSettings.isVpnKillSwitchEnabled,
|
||||
onClick = {
|
||||
toggleVpnKillSwitch()
|
||||
toggleVpnSwitch.invoke(true)
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -124,7 +104,7 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = { toggleLanOnKillSwitch() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) },
|
||||
description = {
|
||||
Text(
|
||||
stringResource(R.string.bypass_lan_for_kill_switch),
|
||||
@@ -135,7 +115,7 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
|
||||
ScaledSwitch(
|
||||
uiState.appSettings.isLanOnKillSwitchEnabled,
|
||||
onClick = {
|
||||
toggleLanOnKillSwitch()
|
||||
viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
+7
-6
@@ -14,19 +14,20 @@ 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.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.SelectedLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
|
||||
fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
|
||||
val locales = LocaleUtil.supportedLocales.map {
|
||||
@@ -57,7 +58,7 @@ fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
|
||||
SelectionItemButton(
|
||||
buttonText = stringResource(R.string.automatic),
|
||||
onClick = {
|
||||
appViewModel.onLocaleChange(LocaleUtil.OPTION_PHONE_LANGUAGE)
|
||||
viewModel.handleEvent(AppEvent.SetLocale(LocaleUtil.OPTION_PHONE_LANGUAGE))
|
||||
},
|
||||
trailing = {
|
||||
with(appUiState.generalState.locale) {
|
||||
@@ -80,7 +81,7 @@ fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
|
||||
""
|
||||
},
|
||||
onClick = {
|
||||
appViewModel.onLocaleChange(locale.toLanguageTag())
|
||||
viewModel.handleEvent(AppEvent.SetLocale(locale.toLanguageTag()))
|
||||
},
|
||||
trailing = {
|
||||
if (locale.toLanguageTag() == appUiState.generalState.locale) {
|
||||
|
||||
+9
-8
@@ -21,20 +21,21 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState) {
|
||||
fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
@@ -77,13 +78,13 @@ fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState)
|
||||
},
|
||||
onClick = {
|
||||
context.launchAppSettings().also {
|
||||
appViewModel.setLocationDisclosureShown()
|
||||
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
context.launchAppSettings().also {
|
||||
appViewModel.setLocationDisclosureShown()
|
||||
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -94,9 +95,9 @@ fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState)
|
||||
listOf(
|
||||
SelectionItem(
|
||||
title = { Text(stringResource(R.string.skip), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
|
||||
onClick = { appViewModel.setLocationDisclosureShown() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
|
||||
trailing = {
|
||||
ForwardButton { appViewModel.setLocationDisclosureShown() }
|
||||
ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+26
-40
@@ -20,7 +20,6 @@ import androidx.compose.material.icons.filled.FolderZip
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.Code
|
||||
import androidx.compose.material.icons.outlined.FolderZip
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
import androidx.compose.material.icons.outlined.Pin
|
||||
import androidx.compose.material.icons.outlined.Restore
|
||||
import androidx.compose.material.icons.outlined.VpnKeyOff
|
||||
@@ -42,30 +41,28 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import xyz.teamgravity.pin_lock_compose.PinManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, uiState: AppUiState) {
|
||||
fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -106,7 +103,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showExportSheet = false
|
||||
viewModel.exportAllConfigs(context, ConfigType.AMNEZIA)
|
||||
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.AMNEZIA))
|
||||
}
|
||||
.padding(10.dp),
|
||||
) {
|
||||
@@ -127,7 +124,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showExportSheet = false
|
||||
viewModel.exportAllConfigs(context, ConfigType.WG)
|
||||
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.WG))
|
||||
}
|
||||
.padding(10.dp),
|
||||
) {
|
||||
@@ -202,7 +199,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
{
|
||||
ScaledSwitch(
|
||||
uiState.appSettings.isShortcutsEnabled,
|
||||
onClick = { appViewModel.onToggleShortcutsEnabled() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
|
||||
)
|
||||
},
|
||||
title = {
|
||||
@@ -211,7 +208,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = { appViewModel.onToggleShortcutsEnabled() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
|
||||
),
|
||||
)
|
||||
if (!isRunningOnTv) {
|
||||
@@ -228,7 +225,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
) &&
|
||||
uiState.appSettings.isAutoTunnelEnabled
|
||||
),
|
||||
onClick = { appViewModel.onToggleAlwaysOnVPN() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
|
||||
checked = uiState.appSettings.isAlwaysOnVpnEnabled,
|
||||
)
|
||||
},
|
||||
@@ -238,7 +235,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = { appViewModel.onToggleAlwaysOnVPN() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -266,7 +263,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
{
|
||||
ScaledSwitch(
|
||||
uiState.appSettings.isRestoreOnBootEnabled,
|
||||
onClick = { appViewModel.onToggleRestartAtBoot() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
|
||||
)
|
||||
},
|
||||
title = {
|
||||
@@ -275,12 +272,21 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = { appViewModel.onToggleRestartAtBoot() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fun onPinLockToggle() {
|
||||
if (uiState.generalState.isPinLockEnabled) {
|
||||
viewModel.handleEvent(AppEvent.TogglePinLock)
|
||||
} else {
|
||||
PinManager.initialize(context)
|
||||
navController.navigate(Route.Lock)
|
||||
}
|
||||
}
|
||||
|
||||
SurfaceSelectionGroupButton(
|
||||
listOf(
|
||||
SelectionItem(
|
||||
@@ -293,16 +299,6 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
ForwardButton { navController.navigate(Route.Appearance) }
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.Outlined.Notifications,
|
||||
title = { Text(stringResource(R.string.notifications), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
|
||||
onClick = {
|
||||
context.launchNotificationSettings()
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton { context.launchNotificationSettings() }
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.Outlined.Pin,
|
||||
title = {
|
||||
@@ -315,22 +311,12 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
ScaledSwitch(
|
||||
uiState.generalState.isPinLockEnabled,
|
||||
onClick = {
|
||||
if (uiState.generalState.isPinLockEnabled) {
|
||||
appViewModel.onPinLockDisabled()
|
||||
} else {
|
||||
PinManager.initialize(context)
|
||||
navController.navigate(Route.Lock)
|
||||
}
|
||||
onPinLockToggle()
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (uiState.generalState.isPinLockEnabled) {
|
||||
appViewModel.onPinLockDisabled()
|
||||
} else {
|
||||
PinManager.initialize(context)
|
||||
navController.navigate(Route.Lock)
|
||||
}
|
||||
onPinLockToggle()
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -351,7 +337,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
uiState.appSettings.isKernelEnabled,
|
||||
onClick = { appViewModel.onToggleKernelMode() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleKernelMode) },
|
||||
enabled = !(
|
||||
uiState.appSettings.isAutoTunnelEnabled ||
|
||||
uiState.appSettings.isAlwaysOnVpnEnabled ||
|
||||
@@ -360,7 +346,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
appViewModel.onToggleKernelMode()
|
||||
viewModel.handleEvent(AppEvent.ToggleKernelMode)
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+21
-22
@@ -4,7 +4,6 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -38,13 +37,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
@@ -53,7 +52,6 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
|
||||
@@ -62,11 +60,12 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel = hiltViewModel()) {
|
||||
fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
@@ -159,12 +158,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
enabled = !appSettings.isAlwaysOnVpnEnabled,
|
||||
checked = appSettings.isTunnelOnWifiEnabled,
|
||||
onClick = {
|
||||
viewModel.onToggleTunnelOnWifi()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi)
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleTunnelOnWifi()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi)
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
@@ -185,12 +184,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
ScaledSwitch(
|
||||
checked = appSettings.isWifiNameByShellEnabled,
|
||||
onClick = {
|
||||
viewModel.onRootShellWifiToggle()
|
||||
viewModel.handleEvent(AppEvent.ToggleRootShellWifi)
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onRootShellWifiToggle()
|
||||
viewModel.handleEvent(AppEvent.ToggleRootShellWifi)
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -213,12 +212,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
ScaledSwitch(
|
||||
checked = appSettings.isWildcardsEnabled,
|
||||
onClick = {
|
||||
viewModel.onToggleWildcards()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards)
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleWildcards()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards)
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
@@ -258,10 +257,10 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
description = {
|
||||
TrustedNetworkTextBox(
|
||||
appSettings.trustedNetworkSSIDs,
|
||||
onDelete = { viewModel.onDeleteTrustedSSID(it) },
|
||||
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
|
||||
currentText = currentText,
|
||||
onSave = { ssid ->
|
||||
if (appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) viewModel.onSaveTrustedSSID(ssid)
|
||||
if (appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
|
||||
},
|
||||
onValueChange = { currentText = it },
|
||||
supporting = {
|
||||
@@ -287,12 +286,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
enabled = appSettings.isVpnKillSwitchEnabled,
|
||||
checked = appSettings.isDisableKillSwitchOnTrustedEnabled,
|
||||
onClick = {
|
||||
viewModel.onToggleStopKillSwitchOnTrusted()
|
||||
viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted)
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleStopKillSwitchOnTrusted()
|
||||
viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted)
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -314,11 +313,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
ScaledSwitch(
|
||||
enabled = !appSettings.isAlwaysOnVpnEnabled,
|
||||
checked = appSettings.isTunnelOnMobileDataEnabled,
|
||||
onClick = { viewModel.onToggleTunnelOnMobileData() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleTunnelOnMobileData()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular)
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
@@ -333,11 +332,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
ScaledSwitch(
|
||||
enabled = !appSettings.isAlwaysOnVpnEnabled,
|
||||
checked = appSettings.isTunnelOnEthernetEnabled,
|
||||
onClick = { viewModel.onToggleTunnelOnEthernet() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleTunnelOnEthernet()
|
||||
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet)
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
@@ -357,11 +356,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
checked = appSettings.isStopOnNoInternetEnabled,
|
||||
onClick = { viewModel.onToggleStopOnNoInternet() },
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.onToggleStopOnNoInternet()
|
||||
viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet)
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
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 androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.components.DebounceDelaySelector
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
|
||||
@Composable
|
||||
fun AdvancedScreen(appUiState: AppUiState) {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopNavBar(stringResource(R.string.advanced_settings))
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 24.dp.scaledHeight())
|
||||
.padding(horizontal = 24.dp.scaledWidth()),
|
||||
) {
|
||||
DebounceDelaySelector(
|
||||
currentDelay = appUiState.appSettings.debounceDelaySeconds,
|
||||
onEvent = appViewModel::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PauseCircle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.DropdownSelector
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) {
|
||||
var isDropDownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
SurfaceSelectionGroupButton(
|
||||
listOf(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Outlined.PauseCircle,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.debounce_delay),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClick = { isDropDownExpanded = true },
|
||||
trailing = {
|
||||
DropdownSelector(
|
||||
currentValue = currentDelay,
|
||||
options = (0..10).toList(),
|
||||
onValueSelected = { num -> onEvent(AppEvent.SetDebounceDelay(num)) },
|
||||
isExpanded = isDropDownExpanded,
|
||||
onDismiss = { isDropDownExpanded = false },
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+21
-199
@@ -7,227 +7,49 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachMoney
|
||||
import androidx.compose.material.icons.filled.Book
|
||||
import androidx.compose.material.icons.filled.Mail
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material.icons.filled.ViewTimeline
|
||||
import androidx.compose.material.icons.outlined.ViewHeadline
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.label.VersionLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.ContactSupportOptions
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.GeneralSupportOptions
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.VersionLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun SupportScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
|
||||
fun SupportScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val isTv = context.isRunningOnTv()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(top = 24.dp.scaledHeight()).padding(horizontal = 24.dp.scaledWidth())
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding().padding(top = 24.dp.scaledHeight())
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp.scaledWidth()),
|
||||
) {
|
||||
GroupLabel(stringResource(R.string.thank_you))
|
||||
SurfaceSelectionGroupButton(
|
||||
buildList {
|
||||
add(
|
||||
SelectionItem(
|
||||
Icons.Filled.Book,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.docs_description),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton { context.openWebUrl(context.getString(R.string.docs_url)) }
|
||||
},
|
||||
onClick = {
|
||||
context.openWebUrl(context.getString(R.string.docs_url))
|
||||
},
|
||||
),
|
||||
)
|
||||
if (!context.isRunningOnTv()) {
|
||||
add(
|
||||
SelectionItem(
|
||||
Icons.Outlined.ViewHeadline,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.local_logging),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
description = {
|
||||
Text(
|
||||
stringResource(R.string.enable_local_logging),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
appUiState.generalState.isLocalLogsEnabled,
|
||||
onClick = {
|
||||
appViewModel.onToggleLocalLogging()
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
appViewModel.onToggleLocalLogging()
|
||||
},
|
||||
),
|
||||
)
|
||||
if (appUiState.generalState.isLocalLogsEnabled) {
|
||||
add(
|
||||
SelectionItem(
|
||||
Icons.Filled.ViewTimeline,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.read_logs),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
navController.navigate(Route.Logs)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
navController.navigate(Route.Logs)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
add(
|
||||
SelectionItem(
|
||||
Icons.Filled.Policy,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.privacy_policy),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton { context.openWebUrl(context.getString(R.string.privacy_policy_url)) }
|
||||
},
|
||||
onClick = {
|
||||
context.openWebUrl(context.getString(R.string.privacy_policy_url))
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
SurfaceSelectionGroupButton(
|
||||
buildList {
|
||||
addAll(
|
||||
listOf(
|
||||
SelectionItem(
|
||||
ImageVector.vectorResource(R.drawable.telegram),
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.chat_description),
|
||||
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
context.openWebUrl(context.getString(R.string.telegram_url))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
context.openWebUrl(context.getString(R.string.telegram_url))
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
ImageVector.vectorResource(R.drawable.github),
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.open_issue),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
context.openWebUrl(context.getString(R.string.github_url))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
context.openWebUrl(context.getString(R.string.github_url))
|
||||
},
|
||||
),
|
||||
SelectionItem(
|
||||
Icons.Filled.Mail,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.email_description),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
context.launchSupportEmail()
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
context.launchSupportEmail()
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
if (BuildConfig.FLAVOR == "fdroid") {
|
||||
add(
|
||||
SelectionItem(
|
||||
Icons.Filled.AttachMoney,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.donate),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
context.openWebUrl(context.getString(R.string.donate_url))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
context.openWebUrl(context.getString(R.string.donate_url))
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
GeneralSupportOptions(
|
||||
context,
|
||||
appUiState,
|
||||
{ viewModel.handleEvent(AppEvent.ToggleLocalLogging) },
|
||||
navController,
|
||||
isTv,
|
||||
)
|
||||
ContactSupportOptions(context)
|
||||
VersionLabel()
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Mail
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
|
||||
@Composable
|
||||
fun ContactSupportOptions(context: android.content.Context) {
|
||||
SurfaceSelectionGroupButton(
|
||||
items = buildList {
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = ImageVector.vectorResource(R.drawable.matrix),
|
||||
title = { SelectionItemLabel(R.string.join_matrix, style = MaterialTheme.typography.bodySmall) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.matrix_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.matrix_url)) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = ImageVector.vectorResource(R.drawable.telegram),
|
||||
title = { SelectionItemLabel(R.string.join_telegram, style = MaterialTheme.typography.bodySmall) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.telegram_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.telegram_url)) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = ImageVector.vectorResource(R.drawable.github),
|
||||
title = { SelectionItemLabel(R.string.open_issue) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.github_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.github_url)) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Filled.Mail,
|
||||
title = { SelectionItemLabel(R.string.email_description) },
|
||||
trailing = { ForwardButton { context.launchSupportEmail() } },
|
||||
onClick = { context.launchSupportEmail() },
|
||||
),
|
||||
)
|
||||
if (BuildConfig.FLAVOR == Constants.FDROID_FLAVOR) {
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Filled.Favorite,
|
||||
title = { SelectionItemLabel(R.string.donate) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.donate_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.donate_url)) },
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Book
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material.icons.filled.ViewTimeline
|
||||
import androidx.compose.material.icons.outlined.ViewHeadline
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Route
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
|
||||
|
||||
@Composable
|
||||
fun GeneralSupportOptions(
|
||||
context: android.content.Context,
|
||||
appUiState: AppUiState,
|
||||
onToggleLocalLogging: () -> Unit,
|
||||
navController: androidx.navigation.NavController,
|
||||
isTv: Boolean,
|
||||
) {
|
||||
SurfaceSelectionGroupButton(
|
||||
items = buildList {
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Filled.Book,
|
||||
title = { SelectionItemLabel(R.string.docs_description) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.docs_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.docs_url)) },
|
||||
),
|
||||
)
|
||||
if (!isTv) {
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Outlined.ViewHeadline,
|
||||
title = { SelectionItemLabel(R.string.local_logging) },
|
||||
description = { SelectionItemLabel(R.string.enable_local_logging, isDescription = true) },
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
checked = appUiState.generalState.isLocalLogsEnabled,
|
||||
onClick = { onToggleLocalLogging() },
|
||||
)
|
||||
},
|
||||
onClick = { onToggleLocalLogging() },
|
||||
),
|
||||
)
|
||||
if (appUiState.generalState.isLocalLogsEnabled) {
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Filled.ViewTimeline,
|
||||
title = { SelectionItemLabel(R.string.read_logs) },
|
||||
trailing = { ForwardButton { navController.navigate(Route.Logs) } },
|
||||
onClick = { navController.navigate(Route.Logs) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
add(
|
||||
SelectionItem(
|
||||
leadingIcon = Icons.Filled.Policy,
|
||||
title = { SelectionItemLabel(R.string.privacy_policy) },
|
||||
trailing = { ForwardButton { context.openWebUrl(context.getString(R.string.privacy_policy_url)) } },
|
||||
onClick = { context.openWebUrl(context.getString(R.string.privacy_policy_url)) },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.label
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
+9
-7
@@ -1,4 +1,4 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -30,24 +30,26 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.LogsViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
|
||||
val logs = viewModel.logs
|
||||
fun LogsScreen() {
|
||||
val appViewModel = hiltViewModel<AppViewModel>()
|
||||
|
||||
val logs by appViewModel.logs.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
|
||||
val lazyColumnListState = rememberLazyListState()
|
||||
@@ -91,7 +93,7 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
viewModel.shareLogs(context)
|
||||
appViewModel.handleEvent(AppEvent.ExportLogs)
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.state
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
|
||||
data class AppState(
|
||||
val isConfigChanged: Boolean = false,
|
||||
val errorMessage: StringValue? = null,
|
||||
val popBackStack: Boolean = false,
|
||||
val isAppReady: Boolean = false,
|
||||
)
|
||||
@@ -8,7 +8,9 @@ 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,
|
||||
val appConfigurationChange: Boolean = false,
|
||||
val isAppLoaded: 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.ui.state
|
||||
import com.wireguard.config.Interface
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList
|
||||
import kotlin.ranges.contains
|
||||
|
||||
data class InterfaceProxy(
|
||||
val privateKey: String = "",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -2,17 +2,16 @@ package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
object Constants {
|
||||
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
|
||||
const val LOG_BUFFER_SIZE = 3_000L
|
||||
const val LOG_BUFFER_SIZE = 10_000L
|
||||
|
||||
const val MANUAL_TUNNEL_CONFIG_ID = 0
|
||||
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
|
||||
const val WATCHER_COLLECTION_DELAY = 3_000L
|
||||
|
||||
const val CONF_FILE_EXTENSION = ".conf"
|
||||
const val ZIP_FILE_EXTENSION = ".zip"
|
||||
const val URI_CONTENT_SCHEME = "content"
|
||||
const val TEXT_MIME_TYPE = "text/plain"
|
||||
const val ZIP_FILE_MIME_TYPE = "application/zip"
|
||||
private const val TEXT_MIME_TYPE = "text/plain"
|
||||
private const val ZIP_FILE_MIME_TYPE = "application/zip"
|
||||
const val ALLOWED_TV_FILE_TYPES = "${TEXT_MIME_TYPE}|${ZIP_FILE_MIME_TYPE}"
|
||||
const val ALL_FILE_TYPES = "*/*"
|
||||
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
|
||||
@@ -29,8 +28,8 @@ object Constants {
|
||||
const val PING_INTERVAL = 60_000L
|
||||
const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour
|
||||
|
||||
const val UNREADABLE_SSID = "<unknown ssid>"
|
||||
|
||||
val amProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
|
||||
const val QR_CODE_NAME_PROPERTY = "# Name ="
|
||||
|
||||
const val FDROID_FLAVOR = "fdroid"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.amnezia.awg.config.Config
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class FileUtils(
|
||||
@@ -16,60 +27,137 @@ class FileUtils(
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> {
|
||||
return withContext(ioDispatcher) {
|
||||
tunnels.map { config ->
|
||||
val file = File(context.cacheDir, "${config.tunName}-wg.conf")
|
||||
file.outputStream().use {
|
||||
it.write(config.wgQuick.toByteArray())
|
||||
}
|
||||
file
|
||||
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> = withContext(ioDispatcher) {
|
||||
tunnels.map { config ->
|
||||
val file = File(context.cacheDir, "${config.tunName}-wg.conf")
|
||||
file.outputStream().use {
|
||||
it.write(config.wgQuick.toByteArray())
|
||||
}
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> {
|
||||
return withContext(ioDispatcher) {
|
||||
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
|
||||
val file = File(context.cacheDir, "${config.tunName}-am.conf")
|
||||
file.outputStream().use {
|
||||
it.write(config.amQuick.toByteArray())
|
||||
}
|
||||
file
|
||||
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> = withContext(ioDispatcher) {
|
||||
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
|
||||
val file = File(context.cacheDir, "${config.tunName}-am.conf")
|
||||
file.outputStream().use {
|
||||
it.write(config.amQuick.toByteArray())
|
||||
}
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun zipAll(zipFile: File, files: List<File>) {
|
||||
withContext(ioDispatcher) {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
files.forEach { file ->
|
||||
val zipFileName = (
|
||||
file.parentFile?.let { parent ->
|
||||
file.absolutePath.removePrefix(parent.absolutePath)
|
||||
} ?: file.absolutePath
|
||||
).removePrefix("/")
|
||||
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
|
||||
zos.putNextEntry(entry)
|
||||
if (file.isFile) {
|
||||
file.inputStream().use {
|
||||
it.copyTo(zos)
|
||||
}
|
||||
suspend fun zipAll(zipFile: File, files: List<File>) = withContext(ioDispatcher) {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||
files.forEach { file ->
|
||||
val zipFileName = (
|
||||
file.parentFile?.let { parent ->
|
||||
file.absolutePath.removePrefix(parent.absolutePath)
|
||||
} ?: file.absolutePath
|
||||
).removePrefix("/")
|
||||
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
|
||||
zos.putNextEntry(entry)
|
||||
if (file.isFile) {
|
||||
file.inputStream().use {
|
||||
it.copyTo(zos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createNewShareFile(name: String): File {
|
||||
return withContext(ioDispatcher) {
|
||||
val sharePath = File(context.filesDir, "external_files")
|
||||
if (sharePath.exists()) sharePath.delete()
|
||||
sharePath.mkdir()
|
||||
val file = File("${sharePath.path}/$name")
|
||||
if (file.exists()) file.delete()
|
||||
file.createNewFile()
|
||||
file
|
||||
suspend fun createNewShareFile(name: String): File = withContext(ioDispatcher) {
|
||||
val sharePath = File(context.filesDir, "external_files")
|
||||
if (sharePath.exists()) sharePath.delete()
|
||||
sharePath.mkdir()
|
||||
val file = File("${sharePath.path}/$name")
|
||||
if (file.exists()) file.delete()
|
||||
file.createNewFile()
|
||||
file
|
||||
}
|
||||
|
||||
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
|
||||
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (columnIndex == -1) return null
|
||||
return columnIndex
|
||||
}
|
||||
|
||||
private fun getDisplayNameByCursor(cursor: Cursor): String? {
|
||||
val move = cursor.moveToFirst()
|
||||
if (!move) return null
|
||||
val index = getDisplayNameColumnIndex(cursor)
|
||||
if (index == null) return index
|
||||
return cursor.getString(index)
|
||||
}
|
||||
|
||||
private fun isValidUriContentScheme(uri: Uri): Boolean {
|
||||
return uri.scheme == Constants.URI_CONTENT_SCHEME
|
||||
}
|
||||
|
||||
private fun getFileName(uri: Uri): String {
|
||||
return getFileNameByCursor(uri) ?: NumberUtils.generateRandomTunnelName()
|
||||
}
|
||||
|
||||
private fun getNameFromFileName(fileName: String): String {
|
||||
return fileName.substring(0, fileName.lastIndexOf('.'))
|
||||
}
|
||||
|
||||
private fun getFileExtensionFromFileName(fileName: String): String? {
|
||||
return try {
|
||||
fileName.substring(fileName.lastIndexOf('.'))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileNameByCursor(uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use {
|
||||
getDisplayNameByCursor(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun buildTunnelsFromUri(uri: Uri): List<TunnelConf> = withContext(ioDispatcher) {
|
||||
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
|
||||
val fileName = getFileName(uri)
|
||||
when (getFileExtensionFromFileName(fileName)) {
|
||||
Constants.CONF_FILE_EXTENSION -> {
|
||||
context.getInputStreamFromUri(uri)?.use { inputStream ->
|
||||
val name = getNameFromFileName(fileName)
|
||||
val amConf = Config.parse(inputStream)
|
||||
listOf(
|
||||
TunnelConf(
|
||||
tunName = name,
|
||||
wgQuick = amConf.toWgQuickString(),
|
||||
amQuick = amConf.toAwgQuickString(true),
|
||||
),
|
||||
)
|
||||
} ?: throw FileReadException
|
||||
}
|
||||
Constants.ZIP_FILE_EXTENSION -> {
|
||||
ZipInputStream(context.getInputStreamFromUri(uri)).use { zip ->
|
||||
generateSequence { zip.nextEntry }
|
||||
.filterNot {
|
||||
it.isDirectory ||
|
||||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
|
||||
}
|
||||
.map { entry ->
|
||||
val name = getNameFromFileName(entry.name)
|
||||
val amConf = Config.parse(zip.bufferedReader())
|
||||
TunnelConf(
|
||||
tunName = name,
|
||||
wgQuick = amConf.toWgQuickString(),
|
||||
amQuick = amConf.toAwgQuickString(true),
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
}
|
||||
else -> throw InvalidFileExtensionException
|
||||
}
|
||||
}
|
||||
|
||||
fun shareFile(shareFile: File) {
|
||||
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
|
||||
context.launchShareFile(uri)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -21,6 +21,7 @@ import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import java.io.InputStream
|
||||
|
||||
private const val BASELINE_HEIGHT = 2201
|
||||
private const val BASELINE_WIDTH = 1080
|
||||
@@ -143,6 +144,10 @@ fun Context.launchVpnSettings(): Result<Unit> {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getInputStreamFromUri(uri: Uri): InputStream? {
|
||||
return this.applicationContext.contentResolver.openInputStream(uri)
|
||||
}
|
||||
|
||||
fun Context.launchLocationServicesSettings(): Result<Unit> {
|
||||
return kotlin.runCatching {
|
||||
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
|
||||
|
||||
+3
-11
@@ -1,9 +1,9 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ObsoleteCoroutinesApi
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.selects.whileSelect
|
||||
import timber.log.Timber
|
||||
@@ -81,13 +80,6 @@ fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
|
||||
}
|
||||
}
|
||||
|
||||
fun Job.cancelWithMessage(message: String) {
|
||||
kotlin.runCatching {
|
||||
cancel()
|
||||
Timber.i(message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> StateFlow<T?>.withData(callback: suspend (T) -> Unit) {
|
||||
return this.filterNotNull().first().let { callback(it) }
|
||||
suspend fun <R> StateFlow<AppUiState>.withFirstState(block: suspend (AppUiState) -> R): R {
|
||||
return block(first { it.isAppLoaded })
|
||||
}
|
||||
|
||||
+15
-9
@@ -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)
|
||||
@@ -86,6 +83,15 @@ fun Config.toWgQuickString(): String {
|
||||
return lines.joinToString(System.lineSeparator())
|
||||
}
|
||||
|
||||
fun Config.defaultName(): String {
|
||||
return try {
|
||||
this.peers[0].endpoint.get().host
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.e(e)
|
||||
NumberUtils.generateRandomTunnelName()
|
||||
}
|
||||
}
|
||||
|
||||
fun Backend.BackendState.asBackendState(): BackendState {
|
||||
return BackendState.valueOf(this.name)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,58 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wireguard.android.backend.WgQuickBackend
|
||||
import com.wireguard.android.util.RootShell
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.di.AppShell
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppState
|
||||
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.ui.theme.Theme
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
|
||||
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 com.zaneschepke.wireguardautotunnel.util.extensions.withFirstState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
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.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.amnezia.awg.config.Config
|
||||
import timber.log.Timber
|
||||
import xyz.teamgravity.pin_lock_compose.PinManager
|
||||
import java.net.URL
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@@ -48,25 +60,27 @@ import javax.inject.Provider
|
||||
class AppViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
appDataRepository: AppDataRepository,
|
||||
val appDataRepository: AppDataRepository,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
@AppShell private val rootShell: Provider<RootShell>,
|
||||
private val tunnelManager: TunnelManager,
|
||||
private val serviceManager: ServiceManager,
|
||||
private val logReader: LogReader,
|
||||
) : BaseViewModel(appDataRepository) {
|
||||
private val fileUtils: FileUtils,
|
||||
private val shortcutManager: ShortcutManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _popBackStack = MutableSharedFlow<Boolean>()
|
||||
val popBackStack = _popBackStack.asSharedFlow()
|
||||
private val tunnelMutex = Mutex()
|
||||
private val settingsMutex = Mutex()
|
||||
private val loggerMutex = Mutex()
|
||||
private val tunControlMutex = Mutex()
|
||||
|
||||
private val _isAppReady = MutableStateFlow(false)
|
||||
val isAppReady = _isAppReady.asStateFlow()
|
||||
private val _appState = MutableStateFlow(AppState())
|
||||
val appState = _appState.asStateFlow()
|
||||
|
||||
private val _configurationChange = MutableStateFlow(false)
|
||||
val configurationChange = _configurationChange.asStateFlow()
|
||||
|
||||
private val _splitTunnelApps = MutableStateFlow<List<SplitTunnelApp>>(emptyList())
|
||||
val splitTunnelApps = _splitTunnelApps.asStateFlow()
|
||||
private val _logs = MutableStateFlow<List<LogMessage>>(emptyList())
|
||||
val logs: StateFlow<List<LogMessage>> = _logs.asStateFlow()
|
||||
|
||||
val uiState =
|
||||
combine(
|
||||
@@ -82,6 +96,7 @@ constructor(
|
||||
activeTunnels,
|
||||
generalState,
|
||||
autoTunnel,
|
||||
isAppLoaded = true,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope + ioDispatcher,
|
||||
@@ -90,310 +105,473 @@ constructor(
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
initPin()
|
||||
handleKillSwitchChange()
|
||||
initServices()
|
||||
launch {
|
||||
initTunnels()
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
uiState.withFirstState { realState ->
|
||||
Timber.d("Real state: $realState")
|
||||
initPin(realState.generalState.isPinLockEnabled)
|
||||
handleKillSwitchChange(realState.appSettings)
|
||||
initServicesFromSavedState(realState)
|
||||
_appState.update { it.copy(isAppReady = true) }
|
||||
}
|
||||
appReadyCheck()
|
||||
uiState.filter { it.generalState.isLocalLogsEnabled }.first()
|
||||
collectLogs()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun appReadyCheck() {
|
||||
val tunnelCount = appDataRepository.tunnels.count()
|
||||
uiState.first { it.tunnels.count() == tunnelCount }
|
||||
_isAppReady.emit(true)
|
||||
fun handleEvent(event: AppEvent) = viewModelScope.launch(ioDispatcher) {
|
||||
uiState.withFirstState { state ->
|
||||
Timber.d("handleEvent: $event")
|
||||
when (event) {
|
||||
AppEvent.ToggleLocalLogging -> onToggleLocalLogging(state.generalState.isLocalLogsEnabled)
|
||||
is AppEvent.SetDebounceDelay -> onSetDebounceDelay(state.appSettings, event.delay)
|
||||
is AppEvent.CopyTunnel -> onCopyTunnel(event.tunnel, state.tunnels)
|
||||
is AppEvent.DeleteTunnel -> onDeleteTunnel(event.tunnel, state)
|
||||
is AppEvent.ImportTunnelFromClipboard -> onClipboardImport(event.text, state.tunnels)
|
||||
is AppEvent.ImportTunnelFromFile -> onImportTunnelFromFile(event.data, state.tunnels)
|
||||
is AppEvent.ImportTunnelFromUrl -> onImportTunnelFromUrl(event.url, state.tunnels)
|
||||
is AppEvent.ImportTunnelFromQrCode -> onImportTunnelFromQr(event.qrCode, state.tunnels)
|
||||
AppEvent.SetBatteryOptimizeDisableShown -> setBatteryOptimizeDisableShown()
|
||||
is AppEvent.StartTunnel -> onStartTunnel(event.tunnel)
|
||||
is AppEvent.StopTunnel -> onStopTunnel(event.tunnel)
|
||||
AppEvent.ToggleAutoTunnel -> onToggleAutoTunnel()
|
||||
AppEvent.ToggleTunnelStatsExpanded -> onToggleExpandTunnelStats(state.generalState.isTunnelStatsExpanded)
|
||||
AppEvent.ToggleAlwaysOn -> onToggleAlwaysOnVPN(state.appSettings)
|
||||
AppEvent.TogglePinLock -> onPinLockToggled(state.generalState.isPinLockEnabled)
|
||||
AppEvent.SetLocationDisclosureShown -> setLocationDisclosureShown()
|
||||
is AppEvent.SetLocale -> onLocaleChange(event.localeTag)
|
||||
AppEvent.ToggleRestartAtBoot -> onToggleRestartAtBoot(state.appSettings)
|
||||
AppEvent.ToggleVpnKillSwitch -> onToggleVpnKillSwitch(state.appSettings)
|
||||
AppEvent.ToggleLanOnKillSwitch -> onToggleLanOnKillSwitch(state.appSettings)
|
||||
AppEvent.ToggleAppShortcuts -> onToggleAppShortcuts(state.appSettings)
|
||||
AppEvent.ToggleKernelMode -> onToggleKernelMode(state.appSettings)
|
||||
is AppEvent.SetTheme -> onThemeChange(event.theme)
|
||||
is AppEvent.ToggleIpv4Preferred -> onToggleIpv4(event.tunnel)
|
||||
is AppEvent.TogglePrimaryTunnel -> onTogglePrimaryTunnel(event.tunnel)
|
||||
is AppEvent.SetTunnelPingCooldown -> onPingCoolDownChange(event.tunnel, event.pingCooldown)
|
||||
is AppEvent.SetTunnelPingInterval -> onPingIntervalChange(event.tunnel, event.pingInterval)
|
||||
is AppEvent.AddTunnelRunSSID -> onAddTunnelRunSSID(event.ssid, event.tunnel, state.tunnels)
|
||||
is AppEvent.DeleteTunnelRunSSID -> onRemoveTunnelRunSSID(event.ssid, event.tunnel)
|
||||
is AppEvent.ToggleEthernetTunnel -> onToggleEthernetTunnel(event.tunnel)
|
||||
is AppEvent.ToggleMobileDataTunnel -> onToggleMobileDataTunnel(event.tunnel)
|
||||
AppEvent.ToggleAutoTunnelOnCellular -> onToggleAutoTunnelOnCellular(state.appSettings)
|
||||
AppEvent.ToggleAutoTunnelOnWifi -> onToggleAutoTunnelOnWifi(state.appSettings)
|
||||
is AppEvent.DeleteTrustedSSID -> onDeleteTrustedSSID(event.ssid, state.appSettings)
|
||||
AppEvent.ToggleAutoTunnelWildcards -> onToggleAutoTunnelWildcards(state.appSettings)
|
||||
AppEvent.ToggleRootShellWifi -> onToggleRootShellWifi(state.appSettings)
|
||||
is AppEvent.SaveTrustedSSID -> onSaveTrustedSSID(event.ssid, state.appSettings)
|
||||
AppEvent.ToggleAutoTunnelOnEthernet -> onToggleTunnelOnEthernet(state.appSettings)
|
||||
AppEvent.ToggleStopKillSwitchOnTrusted -> onToggleStopKillSwitchOnTrusted(state.appSettings)
|
||||
AppEvent.ToggleStopTunnelOnNoInternet -> onToggleStopOnNoInternet(state.appSettings)
|
||||
is AppEvent.ExportTunnels -> onExportTunnels(event.configType, state.tunnels)
|
||||
AppEvent.ExportLogs -> onExportLogs()
|
||||
AppEvent.ErrorShown -> onErrorShown()
|
||||
AppEvent.BackStackPopped -> _appState.update { it.copy(popBackStack = false) }
|
||||
is AppEvent.TogglePingTunnelEnabled -> onTogglePingTunnel(event.tunnel)
|
||||
is AppEvent.SetTunnelPingIp -> onTunnelPingIpChange(event.tunnelConf, event.ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initTunnels() {
|
||||
tunnels.withData { tunnels ->
|
||||
tunnels.filter { it.isActive }.forEach {
|
||||
private fun collectLogs() {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
logReader.bufferedLogs
|
||||
.runningFold(emptyList<LogMessage>()) { accumulator, log ->
|
||||
val updated = accumulator + log
|
||||
if (updated.size > Constants.LOG_BUFFER_SIZE) updated.takeLast(Constants.LOG_BUFFER_SIZE.toInt()) else updated
|
||||
}
|
||||
.collect { _logs.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onTunnelPingIpChange(tunnelConf: TunnelConf, ip: String) = saveTunnel(
|
||||
tunnelConf.copy(pingIp = ip),
|
||||
)
|
||||
|
||||
private suspend fun onTogglePingTunnel(tunnel: TunnelConf) = saveTunnel(
|
||||
tunnel.copy(isPingEnabled = !tunnel.isPingEnabled),
|
||||
)
|
||||
|
||||
private suspend fun onToggleLocalLogging(currentlyEnabled: Boolean) {
|
||||
loggerMutex.withLock {
|
||||
val newEnabled = !currentlyEnabled
|
||||
appDataRepository.appState.setLocalLogsEnabled(newEnabled)
|
||||
withContext(mainDispatcher) {
|
||||
if (newEnabled) logReader.start() else logReader.stop()
|
||||
}
|
||||
if (!newEnabled) _logs.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onSetDebounceDelay(appSettings: AppSettings, delay: Int) = saveSettings(
|
||||
appSettings.copy(debounceDelaySeconds = delay),
|
||||
)
|
||||
|
||||
private suspend fun onCopyTunnel(tunnel: TunnelConf, existingTunnels: List<TunnelConf>) = saveTunnel(
|
||||
TunnelConf(
|
||||
tunName = tunnel.generateUniqueName(existingTunnels.map { it.tunName }),
|
||||
wgQuick = tunnel.wgQuick,
|
||||
amQuick = tunnel.amQuick,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onDeleteTunnel(tunnel: TunnelConf, state: AppUiState) {
|
||||
if (state.tunnels.size == 1 || tunnel.isPrimaryTunnel) {
|
||||
serviceManager.stopAutoTunnel()
|
||||
}
|
||||
appDataRepository.tunnels.delete(tunnel)
|
||||
}
|
||||
|
||||
private suspend fun onStartTunnel(tunnel: TunnelConf) {
|
||||
tunControlMutex.withLock {
|
||||
tunnelManager.startTunnel(tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onStopTunnel(tunnel: TunnelConf) {
|
||||
tunControlMutex.withLock {
|
||||
tunnelManager.stopTunnel(tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onToggleAutoTunnel() {
|
||||
tunControlMutex.withLock {
|
||||
serviceManager.toggleAutoTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onToggleExpandTunnelStats(currentlyEnabled: Boolean) {
|
||||
appDataRepository.appState.setTunnelStatsExpanded(!currentlyEnabled)
|
||||
}
|
||||
|
||||
private fun onErrorShown() {
|
||||
_appState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun onError(message: StringValue) {
|
||||
_appState.update { it.copy(errorMessage = message) }
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
_appState.update { it.copy(popBackStack = true) }
|
||||
}
|
||||
|
||||
private suspend fun onImportTunnelFromFile(uri: Uri, tunnels: List<TunnelConf>) {
|
||||
runCatching {
|
||||
val tunnelConfigs = fileUtils.buildTunnelsFromUri(uri)
|
||||
val existingNames = tunnels.map { it.tunName }.toMutableList()
|
||||
val uniqueTunnelConfigs = tunnelConfigs.map { config ->
|
||||
val uniqueName = config.generateUniqueName(existingNames)
|
||||
existingNames.add(uniqueName)
|
||||
config.copy(tunName = uniqueName)
|
||||
}
|
||||
appDataRepository.tunnels.saveAll(uniqueTunnelConfigs)
|
||||
}.onFailure {
|
||||
// TODO handle exceptions, show message to UI
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onClipboardImport(config: String, tunnels: List<TunnelConf>) {
|
||||
runCatching {
|
||||
val amConfig = TunnelConf.configFromAmQuick(config)
|
||||
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig)
|
||||
saveTunnel(tunnelConf.copy(tunName = tunnelConf.generateUniqueName(tunnels.map { it.tunName })))
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
onError(StringValue.StringResource(R.string.error_file_format))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onImportTunnelFromUrl(urlString: String, tunnels: List<TunnelConf>) {
|
||||
runCatching {
|
||||
val url = URL(urlString)
|
||||
val fileName = urlString.substringAfterLast("/")
|
||||
if (!fileName.endsWith(Constants.CONF_FILE_EXTENSION)) {
|
||||
throw InvalidFileExtensionException
|
||||
}
|
||||
url.openStream().use { stream ->
|
||||
val amConfig = Config.parse(stream)
|
||||
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig)
|
||||
saveTunnel(tunnelConf.copy(tunName = tunnelConf.generateUniqueName(tunnels.map { it.tunName })))
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
val message = when (it) {
|
||||
is InvalidFileExtensionException -> StringValue.StringResource(R.string.error_file_extension)
|
||||
else -> StringValue.StringResource(R.string.error_download_failed)
|
||||
}
|
||||
onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onImportTunnelFromQr(result: String, existingTunnels: List<TunnelConf>) {
|
||||
onClipboardImport(result, existingTunnels)
|
||||
popBackStack()
|
||||
}
|
||||
|
||||
private suspend fun setBatteryOptimizeDisableShown() {
|
||||
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
|
||||
}
|
||||
|
||||
private fun initServicesFromSavedState(state: AppUiState) = viewModelScope.launch(ioDispatcher) {
|
||||
tunControlMutex.withLock {
|
||||
if (state.appSettings.isAutoTunnelEnabled) serviceManager.startAutoTunnel()
|
||||
state.tunnels.filter { it.isActive }.forEach {
|
||||
tunnelManager.startTunnel(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initPin() {
|
||||
val isPinEnabled = appDataRepository.appState.isPinLockEnabled()
|
||||
if (isPinEnabled) PinManager.initialize(WireGuardAutoTunnel.instance)
|
||||
private fun initPin(enabled: Boolean) {
|
||||
if (enabled) PinManager.initialize(WireGuardAutoTunnel.instance)
|
||||
}
|
||||
|
||||
private suspend fun initServices() {
|
||||
withContext(ioDispatcher) {
|
||||
appSettings.withData {
|
||||
if (it.isAutoTunnelEnabled) serviceManager.startAutoTunnel(false)
|
||||
}
|
||||
}
|
||||
private suspend fun onPinLockToggled(currentlyEnabled: Boolean) {
|
||||
if (currentlyEnabled) PinManager.clearPin()
|
||||
appDataRepository.appState.setPinLockEnabled(!currentlyEnabled)
|
||||
}
|
||||
|
||||
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
|
||||
PinManager.clearPin()
|
||||
appDataRepository.appState.setPinLockEnabled(false)
|
||||
}
|
||||
|
||||
fun onPinLockEnabled() = viewModelScope.launch {
|
||||
appDataRepository.appState.setPinLockEnabled(true)
|
||||
}
|
||||
|
||||
fun setLocationDisclosureShown() = viewModelScope.launch {
|
||||
private suspend fun setLocationDisclosureShown() {
|
||||
appDataRepository.appState.setLocationDisclosureShown(true)
|
||||
}
|
||||
|
||||
fun onToggleLocalLogging() = viewModelScope.launch(ioDispatcher) {
|
||||
with(uiState.value.generalState) {
|
||||
val toggledOn = !isLocalLogsEnabled
|
||||
appDataRepository.appState.setLocalLogsEnabled(toggledOn)
|
||||
if (!toggledOn) {
|
||||
logReader.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun onToggleAlwaysOnVPN(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
isAlwaysOnVpnEnabled = !appSettings.isAlwaysOnVpnEnabled,
|
||||
),
|
||||
)
|
||||
|
||||
fun onToggleAlwaysOnVPN() = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
appDataRepository.settings.save(
|
||||
copy(
|
||||
isAlwaysOnVpnEnabled = !isAlwaysOnVpnEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onLocaleChange(localeTag: String) = viewModelScope.launch {
|
||||
private suspend fun onLocaleChange(localeTag: String) {
|
||||
appDataRepository.appState.setLocale(localeTag)
|
||||
LocaleUtil.changeLocale(localeTag)
|
||||
_configurationChange.update {
|
||||
true
|
||||
}
|
||||
_appState.update { it.copy(isConfigChanged = true) }
|
||||
}
|
||||
|
||||
fun onToggleRestartAtBoot() = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
appDataRepository.settings.save(
|
||||
copy(
|
||||
isRestoreOnBootEnabled = !isRestoreOnBootEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
private suspend fun onToggleRestartAtBoot(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
isRestoreOnBootEnabled = !appSettings.isRestoreOnBootEnabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onToggleVpnKillSwitch(appSettings: AppSettings) {
|
||||
val enabled = !appSettings.isVpnKillSwitchEnabled
|
||||
val updatedSettings = appSettings.copy(
|
||||
isVpnKillSwitchEnabled = enabled,
|
||||
isLanOnKillSwitchEnabled = if (enabled) appSettings.isLanOnKillSwitchEnabled else false,
|
||||
)
|
||||
saveSettings(updatedSettings)
|
||||
handleKillSwitchChange(updatedSettings)
|
||||
}
|
||||
|
||||
fun onToggleVpnKillSwitch(enabled: Boolean) = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
appDataRepository.settings.save(
|
||||
copy(
|
||||
isVpnKillSwitchEnabled = enabled,
|
||||
isLanOnKillSwitchEnabled = if (enabled) isLanOnKillSwitchEnabled else false,
|
||||
),
|
||||
)
|
||||
}
|
||||
handleKillSwitchChange()
|
||||
private suspend fun onToggleLanOnKillSwitch(appSettings: AppSettings) {
|
||||
val updatedSettings = appSettings.copy(
|
||||
isLanOnKillSwitchEnabled = !appSettings.isLanOnKillSwitchEnabled,
|
||||
)
|
||||
saveSettings(updatedSettings)
|
||||
handleKillSwitchChange(appSettings)
|
||||
}
|
||||
|
||||
private suspend fun handleKillSwitchChange() {
|
||||
withContext(ioDispatcher) {
|
||||
appSettings.withData {
|
||||
if (!it.isVpnKillSwitchEnabled) return@withData tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
|
||||
Timber.d("Starting kill switch")
|
||||
val allowedIps = if (it.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
|
||||
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
|
||||
}
|
||||
}
|
||||
private suspend fun handleKillSwitchChange(appSettings: AppSettings) {
|
||||
if (!appSettings.isVpnKillSwitchEnabled) return tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
|
||||
Timber.d("Starting kill switch")
|
||||
val allowedIps = if (appSettings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
|
||||
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
|
||||
}
|
||||
|
||||
fun onToggleLanOnKillSwitch(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
|
||||
appDataRepository.settings.save(
|
||||
uiState.value.appSettings.copy(
|
||||
isLanOnKillSwitchEnabled = enabled,
|
||||
private suspend fun onToggleAppShortcuts(appSettings: AppSettings) {
|
||||
val enabled = !appSettings.isShortcutsEnabled
|
||||
if (enabled) shortcutManager.addShortcuts() else shortcutManager.removeShortcuts()
|
||||
saveSettings(
|
||||
appSettings.copy(
|
||||
isShortcutsEnabled = enabled,
|
||||
),
|
||||
)
|
||||
handleKillSwitchChange()
|
||||
}
|
||||
|
||||
fun onToggleShortcutsEnabled() = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
appDataRepository.settings.save(
|
||||
copy(
|
||||
isShortcutsEnabled = !isShortcutsEnabled,
|
||||
),
|
||||
private suspend fun onTogglePrimaryTunnel(tunnelConf: TunnelConf) {
|
||||
tunnelMutex.withLock {
|
||||
appDataRepository.tunnels.updatePrimaryTunnel(
|
||||
when (tunnelConf.isPrimaryTunnel) {
|
||||
true -> null
|
||||
false -> tunnelConf
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveKernelMode(enabled: Boolean) = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
appDataRepository.settings.save(
|
||||
this.copy(
|
||||
isKernelEnabled = enabled,
|
||||
),
|
||||
private suspend fun onToggleIpv4(tunnelConf: TunnelConf) = saveTunnel(
|
||||
tunnelConf.copy(
|
||||
isIpv4Preferred = !tunnelConf.isIpv4Preferred,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onPingIntervalChange(tunnelConf: TunnelConf, interval: String) = saveTunnel(
|
||||
tunnelConf.copy(pingInterval = if (interval.isBlank()) null else interval.toLong() * 1000),
|
||||
)
|
||||
|
||||
private suspend fun onPingCoolDownChange(tunnelConf: TunnelConf, cooldown: String) = saveTunnel(
|
||||
tunnelConf.copy(pingCooldown = if (cooldown.isBlank()) null else cooldown.toLong() * 1000),
|
||||
)
|
||||
|
||||
private suspend fun onThemeChange(theme: Theme) {
|
||||
appDataRepository.appState.setTheme(theme)
|
||||
}
|
||||
|
||||
private suspend fun onToggleKernelMode(appSettings: AppSettings) {
|
||||
val enabled = !appSettings.isKernelEnabled
|
||||
if (enabled && !isKernelSupported()) {
|
||||
onError(StringValue.StringResource(R.string.kernel_not_supported))
|
||||
return
|
||||
}
|
||||
if (enabled && !requestRoot()) return
|
||||
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
|
||||
saveSettings(appSettings.copy(isKernelEnabled = enabled))
|
||||
}
|
||||
|
||||
private suspend fun onRemoveTunnelRunSSID(ssid: String, tunnelConfig: TunnelConf) = saveTunnel(
|
||||
tunnelConfig.copy(
|
||||
tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onAddTunnelRunSSID(ssid: String, tunnelConf: TunnelConf, existingTunnels: List<TunnelConf>) {
|
||||
if (ssid.isBlank()) return
|
||||
val trimmed = ssid.trim()
|
||||
if (existingTunnels.any { it.tunnelNetworks.contains(trimmed) }) return onError(StringValue.StringResource(R.string.error_ssid_exists))
|
||||
saveTunnel(
|
||||
tunnelConf.copy(
|
||||
tunnelNetworks = (tunnelConf.tunnelNetworks + ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onToggleMobileDataTunnel(tunnelConf: TunnelConf) {
|
||||
tunnelMutex.withLock {
|
||||
if (tunnelConf.isMobileDataTunnel) return appDataRepository.tunnels.updateMobileDataTunnel(null)
|
||||
appDataRepository.tunnels.updateMobileDataTunnel(tunnelConf)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onToggleEthernetTunnel(tunnelConf: TunnelConf) {
|
||||
tunnelMutex.withLock {
|
||||
if (tunnelConf.isEthernetTunnel) return appDataRepository.tunnels.updateEthernetTunnel(null)
|
||||
appDataRepository.tunnels.updateEthernetTunnel(tunnelConf)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onToggleAutoTunnelOnWifi(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
isTunnelOnWifiEnabled = !appSettings.isTunnelOnWifiEnabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onToggleAutoTunnelOnCellular(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
isTunnelOnMobileDataEnabled = !appSettings.isTunnelOnMobileDataEnabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onToggleAutoTunnelWildcards(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
isWildcardsEnabled = !appSettings.isWildcardsEnabled,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onDeleteTrustedSSID(ssid: String, appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(
|
||||
trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs - ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun onToggleRootShellWifi(appSettings: AppSettings) {
|
||||
if (requestRoot()) {
|
||||
saveSettings(
|
||||
appSettings.copy(isWifiNameByShellEnabled = !appSettings.isWifiNameByShellEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleKernelMode() = viewModelScope.launch {
|
||||
with(uiState.value.appSettings) {
|
||||
if (!isKernelEnabled) {
|
||||
requestRoot().onSuccess {
|
||||
if (!isKernelSupported()) {
|
||||
return@onSuccess SnackbarController.Companion.showMessage(
|
||||
StringValue.StringResource(R.string.kernel_not_supported),
|
||||
)
|
||||
}
|
||||
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
|
||||
appDataRepository.settings.save(
|
||||
copy(
|
||||
isKernelEnabled = true,
|
||||
isAmneziaEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
saveKernelMode(enabled = false)
|
||||
}
|
||||
}
|
||||
private suspend fun onToggleTunnelOnEthernet(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(isTunnelOnEthernetEnabled = !appSettings.isTunnelOnEthernetEnabled),
|
||||
)
|
||||
|
||||
private suspend fun onSaveTrustedSSID(ssid: String, appSettings: AppSettings) {
|
||||
if (ssid.isEmpty()) return
|
||||
val trimmed = ssid.trim()
|
||||
if (appSettings.trustedNetworkSSIDs.contains(trimmed)) return onError(StringValue.StringResource(R.string.error_ssid_exists))
|
||||
saveSettings(
|
||||
appSettings.copy(
|
||||
trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs + ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onToggleStopOnNoInternet(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(isStopOnNoInternetEnabled = !appSettings.isStopOnNoInternetEnabled),
|
||||
)
|
||||
|
||||
private suspend fun onToggleStopKillSwitchOnTrusted(appSettings: AppSettings) = saveSettings(
|
||||
appSettings.copy(isDisableKillSwitchOnTrustedEnabled = !appSettings.isDisableKillSwitchOnTrustedEnabled),
|
||||
)
|
||||
|
||||
private suspend fun isKernelSupported(): Boolean {
|
||||
return withContext(ioDispatcher) {
|
||||
WgQuickBackend.hasKernelSupport()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
private suspend fun saveSettings(appSettings: AppSettings) = withContext(ioDispatcher) {
|
||||
settingsMutex.withLock {
|
||||
appDataRepository.settings.save(appSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveTunnel(tunnel: TunnelConf) = withContext(ioDispatcher) {
|
||||
tunnelMutex.withLock {
|
||||
appDataRepository.tunnels.save(tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onExportTunnels(configType: ConfigType, tunnels: List<TunnelConf>) {
|
||||
runCatching {
|
||||
val (files, shareFileName) = when (configType) {
|
||||
ConfigType.AMNEZIA -> {
|
||||
Pair(fileUtils.createAmFiles(tunnels), "am-export_${Instant.now().epochSecond}.zip")
|
||||
}
|
||||
_splitTunnelApps.emit(apps)
|
||||
ConfigType.WG -> {
|
||||
Pair(fileUtils.createWgFiles(tunnels), "wg-export_${Instant.now().epochSecond}.zip")
|
||||
}
|
||||
}
|
||||
val shareFile = fileUtils.createNewShareFile(shareFileName)
|
||||
fileUtils.zipAll(shareFile, files)
|
||||
fileUtils.shareFile(shareFile)
|
||||
}.onFailure {
|
||||
// TODO handle error
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestRoot(): Result<Unit> {
|
||||
private suspend fun onExportLogs() {
|
||||
runCatching {
|
||||
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
|
||||
logReader.zipLogFiles(file.absolutePath)
|
||||
fileUtils.shareFile(file)
|
||||
}.onFailure {
|
||||
// TODO handle error
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestRoot(): Boolean {
|
||||
return withContext(ioDispatcher) {
|
||||
runCatching {
|
||||
try {
|
||||
rootShell.get().start()
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.root_accepted))
|
||||
}.onFailure {
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_root_denied))
|
||||
SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wireguard.android.util.RootShell
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.di.AppShell
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@HiltViewModel
|
||||
class AutoTunnelViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
appDataRepository: AppDataRepository,
|
||||
@AppShell private val rootShell: Provider<RootShell>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : BaseViewModel(appDataRepository) {
|
||||
|
||||
fun onToggleTunnelOnWifi() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(
|
||||
isTunnelOnWifiEnabled = !it.isTunnelOnWifiEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(
|
||||
isTunnelOnMobileDataEnabled = !it.isTunnelOnMobileDataEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleWildcards() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(
|
||||
isWildcardsEnabled = !it.isWildcardsEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(
|
||||
trustedNetworkSSIDs = (it.trustedNetworkSSIDs - ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRootShellWifiToggle() = viewModelScope.launch {
|
||||
requestRoot().onSuccess {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(isWifiNameByShellEnabled = !it.isWifiNameByShellEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestRoot(): Result<Unit> {
|
||||
return withContext(ioDispatcher) {
|
||||
runCatching {
|
||||
rootShell.get().start()
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.root_accepted))
|
||||
}.onFailure {
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_root_denied))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(isTunnelOnEthernetEnabled = !it.isTunnelOnEthernetEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
|
||||
if (ssid.isEmpty()) return@launch
|
||||
val trimmed = ssid.trim()
|
||||
appSettings.withData {
|
||||
if (!it.trustedNetworkSSIDs.contains(trimmed)) {
|
||||
saveAppSettings(
|
||||
it.copy(
|
||||
trustedNetworkSSIDs = (it.trustedNetworkSSIDs + ssid).toMutableList(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
SnackbarController.Companion.showMessage(
|
||||
StringValue.StringResource(
|
||||
R.string.error_ssid_exists,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleStopOnNoInternet() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(isStopOnNoInternetEnabled = !it.isStopOnNoInternetEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleStopKillSwitchOnTrusted() = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
saveAppSettings(
|
||||
it.copy(isDisableKillSwitchOnTrustedEnabled = !it.isDisableKillSwitchOnTrustedEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
open class BaseViewModel @Inject constructor(
|
||||
protected val appDataRepository: AppDataRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val appSettings: StateFlow<AppSettings?> = appDataRepository.settings.flow.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Companion.WhileSubscribed(5000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val tunnels: StateFlow<List<TunnelConf>?> = appDataRepository.tunnels.flow.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Companion.WhileSubscribed(5000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
fun saveAppSettings(appSettings: AppSettings) = viewModelScope.launch {
|
||||
appDataRepository.settings.save(appSettings)
|
||||
}
|
||||
|
||||
fun saveTunnel(tunnelConf: TunnelConf) = viewModelScope.launch {
|
||||
appDataRepository.tunnels.save(tunnelConf)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DisplayViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val appStateRepository: AppStateRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
fun onThemeChange(theme: Theme) = viewModelScope.launch {
|
||||
appStateRepository.setTheme(theme)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LogsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val localLogCollector: LogReader,
|
||||
private val fileUtils: FileUtils,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
val logs = mutableStateListOf<LogMessage>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
localLogCollector.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect {
|
||||
withContext(mainDispatcher) {
|
||||
logs.addAll(it)
|
||||
}
|
||||
if (logs.size > Constants.LOG_BUFFER_SIZE) {
|
||||
withContext(mainDispatcher) {
|
||||
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
|
||||
localLogCollector.zipLogFiles(file.absolutePath)
|
||||
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
|
||||
context.launchShareFile(uri)
|
||||
}.onFailure {
|
||||
Timber.Forest.e(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileReadException
|
||||
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
|
||||
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.amnezia.awg.config.Config
|
||||
import timber.log.Timber
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
val tunnelManager: TunnelManager,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val serviceManager: ServiceManager,
|
||||
appDataRepository: AppDataRepository,
|
||||
) : BaseViewModel(appDataRepository) {
|
||||
|
||||
fun onDelete(tunnel: TunnelConf) = viewModelScope.launch {
|
||||
appSettings.withData { settings ->
|
||||
tunnels.withData {
|
||||
if (it.size == 1 || tunnel.isPrimaryTunnel) {
|
||||
serviceManager.stopAutoTunnel()
|
||||
resetTunnelSetting(settings)
|
||||
}
|
||||
appDataRepository.tunnels.delete(tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetTunnelSetting(appSettings: AppSettings) {
|
||||
saveAppSettings(
|
||||
appSettings.copy(
|
||||
isAutoTunnelEnabled = false,
|
||||
isAlwaysOnVpnEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun onExpandedChanged(expanded: Boolean) = viewModelScope.launch {
|
||||
appDataRepository.appState.setTunnelStatsExpanded(expanded)
|
||||
}
|
||||
|
||||
fun onTunnelStart(tunnelConf: TunnelConf) = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
Timber.Forest.i("Starting tunnel ${tunnelConf.tunName}")
|
||||
tunnelManager.startTunnel(tunnelConf)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTunnelStop(tunnelConf: TunnelConf) = viewModelScope.launch {
|
||||
appSettings.withData {
|
||||
tunnelManager.stopTunnel(tunnelConf)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCodeDefaultName(config: String): String {
|
||||
return try {
|
||||
TunnelConf.configFromAmQuick(config).peers[0].endpoint.get().host
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.e(e)
|
||||
NumberUtils.generateRandomTunnelName()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun makeTunnelNameUnique(name: String): String {
|
||||
return withContext(ioDispatcher) {
|
||||
val tunnels = appDataRepository.tunnels.getAll()
|
||||
var tunnelName = name
|
||||
var num = 1
|
||||
while (tunnels.any { it.tunName == tunnelName }) {
|
||||
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
|
||||
"$name($num)"
|
||||
} else {
|
||||
val pair = tunnelName.extractNameAndNumber()
|
||||
"${pair?.first}($num)"
|
||||
}
|
||||
num++
|
||||
}
|
||||
tunnelName
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
|
||||
val amConfig = stream.use { Config.parse(it) }
|
||||
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
|
||||
saveTunnel(
|
||||
TunnelConf(
|
||||
tunName = tunnelName,
|
||||
wgQuick = amConfig.toWgQuickString(),
|
||||
amQuick = amConfig.toAwgQuickString(true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
|
||||
return context.applicationContext.contentResolver.openInputStream(uri)
|
||||
}
|
||||
|
||||
fun onTunnelFileSelected(uri: Uri, context: Context) = viewModelScope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
|
||||
val fileName = getFileName(context, uri)
|
||||
when (getFileExtensionFromFileName(fileName)) {
|
||||
Constants.CONF_FILE_EXTENSION ->
|
||||
saveTunnelFromConfUri(fileName, uri, context)
|
||||
Constants.ZIP_FILE_EXTENSION ->
|
||||
saveTunnelsFromZipUri(
|
||||
uri,
|
||||
context,
|
||||
)
|
||||
else -> throw InvalidFileExtensionException
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.Forest.e(it)
|
||||
if (it is InvalidFileExtensionException) {
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_extension))
|
||||
} else {
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_format))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleAutoTunnel() = viewModelScope.launch {
|
||||
serviceManager.toggleAutoTunnel(false)
|
||||
}
|
||||
|
||||
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) {
|
||||
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
|
||||
generateSequence { zip.nextEntry }
|
||||
.filterNot {
|
||||
it.isDirectory ||
|
||||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
|
||||
}
|
||||
.forEach { entry ->
|
||||
val name = getNameFromFileName(entry.name)
|
||||
val amConf = Config.parse(zip.bufferedReader())
|
||||
saveTunnel(
|
||||
TunnelConf(
|
||||
tunName = makeTunnelNameUnique(name),
|
||||
wgQuick = amConf.toWgQuickString(),
|
||||
amQuick = amConf.toAwgQuickString(true),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBatteryOptimizeDisableShown() = viewModelScope.launch {
|
||||
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
|
||||
}
|
||||
|
||||
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) {
|
||||
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
|
||||
saveTunnelConfigFromStream(stream, name)
|
||||
}
|
||||
|
||||
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use {
|
||||
getDisplayNameByCursor(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
|
||||
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (columnIndex == -1) return null
|
||||
return columnIndex
|
||||
}
|
||||
|
||||
private fun getDisplayNameByCursor(cursor: Cursor): String? {
|
||||
val move = cursor.moveToFirst()
|
||||
if (!move) return null
|
||||
val index = getDisplayNameColumnIndex(cursor)
|
||||
if (index == null) return index
|
||||
return cursor.getString(index)
|
||||
}
|
||||
|
||||
private fun isValidUriContentScheme(uri: Uri): Boolean {
|
||||
return uri.scheme == Constants.URI_CONTENT_SCHEME
|
||||
}
|
||||
|
||||
private fun getFileName(context: Context, uri: Uri): String {
|
||||
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
|
||||
}
|
||||
|
||||
private fun getNameFromFileName(fileName: String): String {
|
||||
return fileName.substring(0, fileName.lastIndexOf('.'))
|
||||
}
|
||||
|
||||
private fun getFileExtensionFromFileName(fileName: String): String? {
|
||||
return try {
|
||||
fileName.substring(fileName.lastIndexOf('.'))
|
||||
} catch (e: Exception) {
|
||||
Timber.Forest.e(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun onCopyTunnel(tunnel: TunnelConf) = viewModelScope.launch {
|
||||
saveTunnel(
|
||||
TunnelConf(
|
||||
tunName = makeTunnelNameUnique(tunnel.tunName),
|
||||
wgQuick = tunnel.wgQuick,
|
||||
amQuick = tunnel.amQuick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun onClipboardImport(config: String) = viewModelScope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
val amConfig = TunnelConf.configFromAmQuick(config)
|
||||
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig, makeTunnelNameUnique(generateQrCodeDefaultName(config)))
|
||||
saveTunnel(tunnelConf)
|
||||
}.onFailure {
|
||||
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_format))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user