Compare commits

..

1 Commits

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 14:03:24 +00:00
160 changed files with 3385 additions and 4251 deletions
+6 -3
View File
@@ -86,6 +86,7 @@ android {
} }
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
resValue("string", "app_name", "WG Tunnel - Debug") resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"") resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
@@ -94,6 +95,7 @@ android {
create(Constants.PRERELEASE) { create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE)) initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
versionNameSuffix = "-pre"
resValue("string", "app_name", "WG Tunnel - Pre") resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"") resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
} }
@@ -101,6 +103,7 @@ android {
create(Constants.NIGHTLY) { create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE)) initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly" applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "WG Tunnel - Nightly") resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"") resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
} }
@@ -157,7 +160,6 @@ dependencies {
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.material)
implementation(libs.androidx.storage)
// test // test
testImplementation(libs.junit) testImplementation(libs.junit)
@@ -202,12 +204,13 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
// icons
implementation(libs.material.icons.extended)
// serialization // serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// ui // barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.material.icons.extended)
// bio // bio
implementation(libs.androidx.biometric.ktx) implementation(libs.androidx.biometric.ktx)
+1 -1
View File
@@ -63,7 +63,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustNothing" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.WireguardAutoTunnel" android:theme="@style/Theme.WireguardAutoTunnel"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
> >
@@ -1,35 +1,22 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
@@ -37,16 +24,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -54,44 +36,39 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.CustomBottomNavbar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.DynamicTopAppBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.currentNavBackStackEntryAsNavBarState
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar 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.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen 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.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.KillSwitchScreen
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.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.AppearanceScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.amnezia.awg.backend.GoBackend.VpnService
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -110,7 +87,6 @@ class MainActivity : AppCompatActivity() {
private var lastLocationPermissionState: Boolean? = null private var lastLocationPermissionState: Boolean? = null
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT), statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -125,47 +101,23 @@ class MainActivity : AppCompatActivity() {
installSplashScreen().apply { installSplashScreen().apply {
setKeepOnScreenCondition { setKeepOnScreenCondition {
!viewModel.appViewState.value.isAppReady !viewModel.appState.value.isAppReady
} }
} }
setContent { setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle() val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle() val appState by viewModel.appState.collectAsStateWithLifecycle()
if (!appState.isAppReady) {
Box(modifier = Modifier.fillMaxSize())
return@setContent
}
val navController = rememberNavController() val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState() val snackbarController = SnackbarController.current
val navBarState by currentNavBackStackEntryAsNavBarState(navController, backStackEntry, viewModel, appUiState)
val snackbar = remember { SnackbarHostState() }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var vpnPermissionDenied by remember { mutableStateOf(false) }
val vpnActivity = with(appState) {
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) {
showVpnPermissionDialog = true
vpnPermissionDenied = true
} else {
vpnPermissionDenied = false
}
},
)
LaunchedEffect(appUiState.tunnels) {
if (!appViewState.isAppReady) {
viewModel.handleEvent(AppEvent.AppReadyCheck(appUiState.tunnels))
}
}
val batteryActivity = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _: ActivityResult ->
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
with(appViewState) {
LaunchedEffect(isConfigChanged) { LaunchedEffect(isConfigChanged) {
if (isConfigChanged) { if (isConfigChanged) {
Intent(this@MainActivity, MainActivity::class.java).also { Intent(this@MainActivity, MainActivity::class.java).also {
@@ -176,193 +128,128 @@ class MainActivity : AppCompatActivity() {
} }
LaunchedEffect(errorMessage) { LaunchedEffect(errorMessage) {
errorMessage?.let { errorMessage?.let {
snackbar.showSnackbar(it.asString(this@MainActivity)) snackbarController.showMessage(it.asString(this@MainActivity))
viewModel.handleEvent(AppEvent.MessageShown)
} }
} }
LaunchedEffect(appUiState.activeTunnels) {
appUiState.activeTunnels
.mapNotNull { (tunnelConf, tunnelState) ->
(tunnelState.status as? TunnelStatus.Error)?.let { error ->
val message = error.error.toStringRes()
val context = this@MainActivity
snackbar.showSnackbar(context.getString(R.string.tunnel_error_template, context.getString(message)))
viewModel.handleEvent(AppEvent.ClearTunnelError(tunnelConf))
}
}
}
LaunchedEffect(popBackStack) { LaunchedEffect(popBackStack) {
if (popBackStack) { if (popBackStack) {
navController.popBackStack() navController.popBackStack()
viewModel.handleEvent(AppEvent.PopBackStack(false)) viewModel.handleEvent(AppEvent.BackStackPopped)
}
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
if (!vpnPermissionDenied) {
vpnActivity.launch(VpnService.prepare(this@MainActivity))
} else {
showVpnPermissionDialog = true
}
viewModel.handleEvent(AppEvent.VpnPermissionRequested)
}
}
LaunchedEffect(requestBatteryPermission) {
if (requestBatteryPermission) {
batteryActivity.launch(
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${this@MainActivity.packageName}")
},
)
} }
} }
} }
CompositionLocalProvider(LocalNavController provides navController) { CompositionLocalProvider(LocalNavController provides navController) {
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) { SnackbarControllerProvider { host ->
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false }) WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
Scaffold(
Scaffold( contentWindowInsets = WindowInsets(0),
modifier = Modifier.pointerInput(Unit) { snackbarHost = {
detectTapGestures { SnackbarHost(host) { snackbarData: SnackbarData ->
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null)) CustomSnackBar(
} snackbarData.visuals.message,
}, isRtl = false,
snackbarHost = { containerColor =
SnackbarHost(snackbar) { snackbarData: SnackbarData -> MaterialTheme.colorScheme.surfaceColorAtElevation(
CustomSnackBar( 2.dp,
snackbarData.visuals.message, ),
isRtl = false, )
containerColor = }
MaterialTheme.colorScheme.surfaceColorAtElevation( },
2.dp, bottomBar = {
), BottomNavBar(
) navController,
}
},
topBar = {
DynamicTopAppBar(navBarState)
},
bottomBar = {
AnimatedVisibility(
visible = navBarState.showBottom,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
CustomBottomNavbar(
listOf( listOf(
BottomNavItem( BottomNavItem(
name = stringResource(R.string.tunnels), name = stringResource(R.string.tunnels),
route = Route.Main, route = Route.Main,
icon = Icons.Rounded.Home, icon = Icons.Rounded.Home,
onClick = { navController.goFromRoot(Route.Main) },
),
BottomNavItem(
name = stringResource(R.string.auto_tunnel),
route = Route.AutoTunnel,
icon = Icons.Rounded.Bolt,
onClick = {
val route = if (appUiState.generalState.isLocationDisclosureShown) Route.AutoTunnel else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
), ),
BottomNavItem( BottomNavItem(
name = stringResource(R.string.settings), name = stringResource(R.string.settings),
route = Route.Settings, route = Route.Settings,
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings,
onClick = { navController.goFromRoot(Route.Settings) },
), ),
BottomNavItem( BottomNavItem(
name = stringResource(R.string.support), name = stringResource(R.string.support),
route = Route.Support, route = Route.Support,
icon = Icons.Rounded.QuestionMark, icon = Icons.Rounded.QuestionMark,
onClick = { navController.goFromRoot(Route.Support) },
), ),
), ),
navBarState = navBarState,
) )
} },
}, ) { padding ->
) { padding -> Box(modifier = Modifier.Companion.fillMaxSize().padding(padding)) {
Box( NavHost(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface) navController,
.padding(padding) startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
.consumeWindowInsets(padding) ) {
.imePadding(), composable<Route.Main> {
) { MainScreen(appUiState, viewModel)
NavHost( }
navController, composable<Route.Settings> {
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main), SettingsScreen(appUiState, viewModel)
) { }
composable<Route.Main> { composable<Route.LocationDisclosure> {
MainScreen(appUiState, appViewState, viewModel) LocationDisclosureScreen(appUiState, viewModel)
} }
composable<Route.Settings> { composable<Route.AutoTunnel> {
SettingsScreen(appUiState, appViewState, viewModel) AutoTunnelScreen(appUiState.appSettings, viewModel)
} }
composable<Route.LocationDisclosure> { composable<Route.Appearance> {
LocationDisclosureScreen(appUiState, viewModel) AppearanceScreen()
} }
composable<Route.AutoTunnel> { composable<Route.Language> {
AutoTunnelScreen(appUiState, viewModel) LanguageScreen(appUiState, viewModel)
} }
composable<Route.Appearance> { composable<Route.Display> {
AppearanceScreen() DisplayScreen(appUiState, viewModel)
} }
composable<Route.Language> { composable<Route.Support> {
LanguageScreen(appUiState, viewModel) SupportScreen(appUiState, viewModel)
} }
composable<Route.Display> { composable<Route.AutoTunnelAdvanced> {
DisplayScreen(appUiState, viewModel) AdvancedScreen(appUiState)
} }
composable<Route.Support> { composable<Route.Logs> {
SupportScreen() LogsScreen()
} }
composable<Route.AutoTunnelAdvanced> { composable<Route.Config> { backStack ->
AdvancedScreen(appUiState) val args = backStack.toRoute<Route.Config>()
} val config = appUiState.tunnels.firstOrNull { it.id == args.id }
composable<Route.Logs> { ConfigScreen(config)
LogsScreen(appViewState, viewModel) }
} composable<Route.TunnelOptions> { backStack ->
composable<Route.Config> { backStack -> val args = backStack.toRoute<Route.TunnelOptions>()
val args = backStack.toRoute<Route.Config>() appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
val config = appUiState.tunnels.firstOrNull { it.id == args.id } OptionsScreen(config, appUiState, viewModel)
ConfigScreen(config, viewModel) }
} }
composable<Route.TunnelOptions> { backStack -> composable<Route.Lock> {
val args = backStack.toRoute<Route.TunnelOptions>() PinLockScreen(viewModel)
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config -> }
TunnelOptionsScreen(config, appUiState, viewModel) composable<Route.Scanner> {
ScannerScreen(viewModel)
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, 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, viewModel)
}
} }
} }
composable<Route.Lock> { BackHandler {
PinLockScreen(viewModel) if (navController.previousBackStackEntry == null || !navController.popBackStack()) {
} this@MainActivity.finish()
composable<Route.Scanner> {
ScannerScreen(viewModel)
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
composable<Route.SplitTunnel> {
SplitTunnelScreen(viewModel)
}
composable<Route.TunnelAutoTunnel> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
TunnelAutoTunnelScreen(it, appUiState.appSettings, viewModel)
} }
} }
} }
BackHandler {
if (navController.currentDestination?.route != Route.Main::class.qualifiedName) {
navController.popBackStack()
} else {
this@MainActivity.finish()
}
}
} }
} }
} }
@@ -19,6 +19,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -86,7 +87,7 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
tunnelManager.startTunnel(it) tunnelManager.startTunnel(it)
} }
} else { } else {
Timber.w("Always-on VPN is not enabled in app settings") Timber.Forest.w("Always-on VPN is not enabled in app settings")
} }
} }
} }
@@ -94,6 +95,9 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
ServiceWorker.start(this) ServiceWorker.start(this)
applicationScope.launch { applicationScope.launch {
withContext(mainDispatcher) {
if (appDataRepository.appState.isLocalLogsEnabled() && !isRunningOnTv()) logReader.start()
}
if (!appDataRepository.settings.get().isKernelEnabled) { if (!appDataRepository.settings.get().isKernelEnabled) {
tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList()) tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
} }
@@ -102,9 +106,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
LocaleUtil.changeLocale(it) LocaleUtil.changeLocale(it)
} }
} }
appDataRepository.appState.isLocalLogsEnabled().let { enabled ->
if (enabled) logReader.start()
}
} }
} }
@@ -134,19 +135,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
return foreground return foreground
} }
@Volatile
private var lastActiveTunnels: List<Int> = emptyList()
@Synchronized
fun getLastActiveTunnels(): List<Int> {
return lastActiveTunnels
}
@Synchronized
fun setLastActiveTunnels(newTunnels: List<Int>) {
lastActiveTunnels = newTunnels
}
lateinit var instance: WireGuardAutoTunnel lateinit var instance: WireGuardAutoTunnel
private set private set
} }
@@ -36,15 +36,11 @@ class NotificationActionReceiver : BroadcastReceiver() {
NotificationAction.AUTO_TUNNEL_OFF.name -> serviceManager.stopAutoTunnel() NotificationAction.AUTO_TUNNEL_OFF.name -> serviceManager.stopAutoTunnel()
NotificationAction.TUNNEL_OFF.name -> { NotificationAction.TUNNEL_OFF.name -> {
val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0) val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0)
if (tunnelId == STOP_ALL_TUNNELS_ID) return@launch tunnelManager.stopTunnel() if (tunnelId == 0) return@launch tunnelManager.stopTunnel()
val tunnel = tunnelRepository.getById(tunnelId) val tunnel = tunnelRepository.getById(tunnelId)
tunnelManager.stopTunnel(tunnel) tunnelManager.stopTunnel(tunnel)
} }
} }
} }
} }
companion object {
const val STOP_ALL_TUNNELS_ID = 0
}
} }
@@ -44,9 +44,9 @@ class RestartReceiver : BroadcastReceiver() {
} }
Timber.d("RestartReceiver triggered with action: ${intent.action}") Timber.d("RestartReceiver triggered with action: ${intent.action}")
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
applicationScope.launch(ioDispatcher) { applicationScope.launch(ioDispatcher) {
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
val settings = appDataRepository.settings.get() val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) { if (settings.isRestoreOnBootEnabled) {
if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) { if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) {
@@ -39,6 +39,7 @@ interface NotificationManager {
fun show(notificationId: Int, notification: Notification) fun show(notificationId: Int, notification: Notification)
companion object { companion object {
const val KERNEL_SERVICE_NOTIFICATION_ID = 123
const val AUTO_TUNNEL_NOTIFICATION_ID = 122 const val AUTO_TUNNEL_NOTIFICATION_ID = 122
const val VPN_NOTIFICATION_ID = 100 const val VPN_NOTIFICATION_ID = 100
const val EXTRA_ID = "id" const val EXTRA_ID = "id"
@@ -3,12 +3,11 @@ package com.zaneschepke.wireguardautotunnel.core.service
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.VpnService
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
@@ -20,21 +19,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
class ServiceManager @Inject constructor( class ServiceManager @Inject constructor(
private val context: Context, private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
) { ) {
private val autoTunnelMutex = Mutex()
private val _autoTunnelActive = MutableStateFlow(false) private val _autoTunnelActive = MutableStateFlow(false)
val autoTunnelActive = _autoTunnelActive.asStateFlow() val autoTunnelActive = _autoTunnelActive.asStateFlow()
@@ -52,17 +46,13 @@ class ServiceManager @Inject constructor(
}.onFailure { Timber.e(it) } }.onFailure { Timber.e(it) }
} }
fun hasVpnPermission(): Boolean { fun startAutoTunnel() {
return VpnService.prepare(context) == null applicationScope.launch(ioDispatcher) {
}
suspend fun startAutoTunnel() {
autoTunnelMutex.withLock {
val settings = appDataRepository.settings.get() val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true)) appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
if (autoTunnelService.isCompleted) { if (autoTunnelService.isCompleted) {
_autoTunnelActive.update { true } _autoTunnelActive.update { true }
return return@launch
} }
runCatching { runCatching {
autoTunnelService = CompletableDeferred() autoTunnelService = CompletableDeferred()
@@ -72,45 +62,47 @@ class ServiceManager @Inject constructor(
Timber.e(it) Timber.e(it)
_autoTunnelActive.update { false } _autoTunnelActive.update { false }
} }
withContext(mainDispatcher) { updateAutoTunnelTile() }
} }
updateAutoTunnelTile()
} }
suspend fun stopAutoTunnel() { fun startTunnelForegroundService(tunnelConf: TunnelConf) {
autoTunnelMutex.withLock { applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get() if (backgroundService.isCompleted) return@launch
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = false))
if (!autoTunnelService.isCompleted) return
runCatching { runCatching {
val service = autoTunnelService.await() backgroundService = CompletableDeferred()
service.stop() startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground())
_autoTunnelActive.update { false } val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { backgroundService.await() }
autoTunnelService = CompletableDeferred() ?: throw IllegalStateException("Background service start timed out")
service.start(tunnelConf)
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
withContext(mainDispatcher) { updateAutoTunnelTile() }
} }
} }
fun startTunnelForegroundService() { fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
if (backgroundService.isCompleted) return applicationScope.launch(ioDispatcher) {
runCatching { if (!backgroundService.isCompleted) return@launch
backgroundService = CompletableDeferred() runCatching {
startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground()) val service = backgroundService.await()
}.onFailure { service.start(tunnelConf)
Timber.e(it) }.onFailure {
Timber.e(it)
}
} }
} }
suspend fun stopTunnelForegroundService() { fun stopTunnelForegroundService() {
if (!backgroundService.isCompleted) return applicationScope.launch(ioDispatcher) {
runCatching { if (!backgroundService.isCompleted) return@launch
val service = backgroundService.await() runCatching {
service.stop() val service = backgroundService.await()
backgroundService = CompletableDeferred() service.stop()
}.onFailure { backgroundService = CompletableDeferred()
Timber.e(it) }.onFailure {
Timber.e(it)
}
} }
} }
@@ -127,4 +119,25 @@ class ServiceManager @Inject constructor(
fun updateTunnelTile() { fun updateTunnelTile() {
context.requestTunnelTileServiceStateUpdate() context.requestTunnelTileServiceStateUpdate()
} }
fun stopAutoTunnel() {
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = false))
if (!autoTunnelService.isCompleted) return@launch
runCatching {
val service = autoTunnelService.await()
service.stop()
_autoTunnelActive.update { false }
autoTunnelService = CompletableDeferred()
updateAutoTunnelTile()
}.onFailure {
Timber.e(it)
}
}
}
companion object {
const val SERVICE_START_TIMEOUT = 5_000L
}
} }
@@ -11,20 +11,14 @@ import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -34,9 +28,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -58,22 +50,11 @@ class TunnelForegroundService : LifecycleService() {
@Inject @Inject
lateinit var tunnelRepo: TunnelRepository lateinit var tunnelRepo: TunnelRepository
@Inject
lateinit var tunnelManager: TunnelManager
private val isNetworkConnected = MutableStateFlow(true) private val isNetworkConnected = MutableStateFlow(true)
private val tunnelJobs = ConcurrentHashMap<TunnelConf, Job>()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.backgroundService.complete(this) serviceManager.backgroundService.complete(this)
ServiceCompat.startForeground(
this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID,
onCreateNotification(),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@@ -82,78 +63,31 @@ class TunnelForegroundService : LifecycleService() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
serviceManager.backgroundService.complete(this) serviceManager.backgroundService.complete(this)
return super.onStartCommand(intent, flags, startId)
}
fun start(tunnelConf: TunnelConf) {
Timber.d("Service starting with TunnelConf instance: ${tunnelConf.hashCode()}")
ServiceCompat.startForeground( ServiceCompat.startForeground(
this@TunnelForegroundService, this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID, NotificationManager.KERNEL_SERVICE_NOTIFICATION_ID,
onCreateNotification(), createNotification(tunnelConf),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
start()
return START_STICKY
}
fun start() = lifecycleScope.launch {
tunnelManager.activeTunnels.distinctByKeys().collect { tuns ->
if (tuns.isEmpty() && tunnelJobs.isEmpty()) return@collect
if (tuns.isEmpty() && tunnelJobs.isNotEmpty()) {
return@collect tunnelJobs.forEach { (key, _) ->
Timber.d("Stopping all tunnel jobs")
tunnelJobs[key]?.cancel()
tunnelJobs.remove(key)
}
}
val (jobsToStop, jobsToStart) = findMissingKeys(tuns, tunnelJobs)
if (jobsToStop.isEmpty() && jobsToStart.isEmpty()) return@collect
jobsToStop.forEach { tun ->
Timber.d("Stopping tunnel jobs for ${tun.tunName}")
tunnelJobs[tun]?.cancel()
tunnelJobs.remove(tun)
}
jobsToStart.forEach { tun ->
Timber.d("Starting tunnel jobs for ${tun.tunName}")
tunnelJobs += (tun to startTunnelJobs(tun))
}
updateServiceNotification()
}
}
// TODO Would be cool to have this include kill switch
// TODO also we need to include errors
private fun updateServiceNotification() {
val notification = when (tunnelJobs.size) {
0 -> onCreateNotification()
1 -> createTunnelNotification(tunnelJobs.keys.first())
else -> createTunnelsNotification()
}
ServiceCompat.startForeground(
this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
// use same scope so we can cancel all of these
private fun startTunnelJobs(tunnelConf: TunnelConf) = lifecycleScope.launch {
// monitor if we have internet connectivity // monitor if we have internet connectivity
launch { startNetworkMonitorJob() } startNetworkMonitorJob()
// job to trigger stats emit on interval // job to trigger stats emit on interval
launch { startTunnelStatsJob(tunnelConf) } startTunnelStatsJob(tunnelConf)
// monitor changes to the tunnel config // monitor changes to the tunnel config
launch { startTunnelConfChangesJob(tunnelConf) } startTunnelConfChangesJob(tunnelConf)
// monitor tunnel ping
launch { startPingJob(tunnelConf) } startPingJob(tunnelConf)
} }
private fun findMissingKeys(map1: Map<TunnelConf, Any>, map2: Map<TunnelConf, Any>): Pair<Set<TunnelConf>, Set<TunnelConf>> { private fun startTunnelConfChangesJob(tunnelConf: TunnelConf) = lifecycleScope.launch(ioDispatcher) {
val missingMap1 = map2.keys - map1.keys
val missingMap2 = map1.keys - map2.keys
return missingMap1 to missingMap2
}
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
tunnelRepo.flow tunnelRepo.flow
.flowOn(ioDispatcher) .flowOn(ioDispatcher)
.map { storedTunnels -> .map { storedTunnels ->
@@ -162,20 +96,17 @@ class TunnelForegroundService : LifecycleService() {
.filterNotNull() .filterNotNull()
// only emit when one of these 3 values change // only emit when one of these 3 values change
.distinctUntilChanged { old, new -> .distinctUntilChanged { old, new ->
old == new old.tunName == new.tunName && old.wgQuick == new.wgQuick && old.amQuick == new.amQuick
} }
.collect { storedTunnel -> .collect { storedTunnel ->
if (tunnelConf != storedTunnel) { if (tunnelConf.isTunnelConfigChanged(storedTunnel)) {
Timber.d("Config changed for ${storedTunnel.tunName}, bouncing") Timber.d("Config changed for ${storedTunnel.tunName}, bouncing")
// let this complete, even after cancel tunnelConf.bounceTunnel(storedTunnel)
withContext(NonCancellable) {
tunnelManager.bounceTunnel(storedTunnel, TunnelStatus.StopReason.CONFIG_CHANGED)
}
} }
} }
} }
private suspend fun startNetworkMonitorJob() { private fun startNetworkMonitorJob() = lifecycleScope.launch(ioDispatcher) {
networkMonitor.networkStatusFlow networkMonitor.networkStatusFlow
.flowOn(ioDispatcher) .flowOn(ioDispatcher)
.collectLatest { status -> .collectLatest { status ->
@@ -185,23 +116,19 @@ class TunnelForegroundService : LifecycleService() {
} }
} }
private suspend fun startTunnelStatsJob(tunnel: TunnelConf) = coroutineScope { private fun startTunnelStatsJob(tunnel: TunnelConf) = lifecycleScope.launch(ioDispatcher) {
while (isActive) { while (isActive) {
tunnelManager.updateTunnelStatistics(tunnel) tunnel.onUpdateStatistics()
delay(STATS_DELAY) delay(STATS_DELAY)
} }
} }
// TODO fix cooldown private fun startPingJob(tunnel: TunnelConf) = lifecycleScope.launch(ioDispatcher) {
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
delay(PING_START_DELAY) delay(PING_START_DELAY)
while (isActive) { while (isActive) {
val shouldBounce = shouldBounceTunnel(tunnel) val shouldBounce = shouldBounceTunnel(tunnel)
val delayMs = if (shouldBounce) { val delayMs = if (shouldBounce) {
// let this complete, even after cancel tunnel.bounceTunnel(tunnel)
withContext(NonCancellable) {
tunnelManager.bounceTunnel(tunnel, TunnelStatus.StopReason.PING)
}
tunnel.pingCooldown ?: Constants.PING_COOLDOWN tunnel.pingCooldown ?: Constants.PING_COOLDOWN
} else { } else {
tunnel.pingInterval ?: Constants.PING_INTERVAL tunnel.pingInterval ?: Constants.PING_INTERVAL
@@ -223,7 +150,6 @@ class TunnelForegroundService : LifecycleService() {
} }
fun stop() { fun stop() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
@@ -233,7 +159,7 @@ class TunnelForegroundService : LifecycleService() {
super.onDestroy() super.onDestroy()
} }
private fun createTunnelNotification(tunnelConf: TunnelConf): Notification { private fun createNotification(tunnelConf: TunnelConf): Notification {
return notificationManager.createNotification( return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN, WireGuardNotification.NotificationChannels.VPN,
title = "${getString(R.string.tunnel_running)} - ${tunnelConf.tunName}", title = "${getString(R.string.tunnel_running)} - ${tunnelConf.tunName}",
@@ -243,24 +169,6 @@ class TunnelForegroundService : LifecycleService() {
) )
} }
private fun createTunnelsNotification(): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = "${getString(R.string.tunnel_running)} - ${getString(R.string.multiple)}",
actions = listOf(
notificationManager.createNotificationAction(NotificationAction.TUNNEL_OFF, 0),
),
)
}
private fun onCreateNotification(): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = getString(R.string.tunnel_starting),
)
}
// TODO add notification handling and optional log reading for restart on handshake failures
companion object { companion object {
const val STATS_DELAY = 1_000L const val STATS_DELAY = 1_000L
const val PING_START_DELAY = 30_000L const val PING_START_DELAY = 30_000L
@@ -14,8 +14,8 @@ import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotificati
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings 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.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
@@ -30,7 +30,6 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -64,6 +63,10 @@ class AutoTunnelService : LifecycleService() {
@Inject @Inject
lateinit var serviceManager: ServiceManager lateinit var serviceManager: ServiceManager
@Inject
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
@Inject @Inject
lateinit var tunnelManager: TunnelManager lateinit var tunnelManager: TunnelManager
@@ -73,12 +76,16 @@ class AutoTunnelService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var killSwitchJob: Job? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.autoTunnelService.complete(this) serviceManager.autoTunnelService.complete(this)
launchWatcherNotification() lifecycleScope.launch(mainImmediateDispatcher) {
runCatching {
launchWatcherNotification()
}.onFailure {
Timber.e(it)
}
}
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@@ -96,11 +103,13 @@ class AutoTunnelService : LifecycleService() {
fun start() { fun start() {
kotlin.runCatching { kotlin.runCatching {
launchWatcherNotification() lifecycleScope.launch(mainImmediateDispatcher) {
initWakeLock() launchWatcherNotification()
initWakeLock()
}
startAutoTunnelJob() startAutoTunnelJob()
startAutoTunnelStateJob() startAutoTunnelStateJob()
killSwitchJob = startKillSwitchJob() startKillSwitchJob()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
@@ -113,20 +122,9 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() { override fun onDestroy() {
serviceManager.autoTunnelService = CompletableDeferred() serviceManager.autoTunnelService = CompletableDeferred()
restoreVpnKillSwitch()
super.onDestroy() super.onDestroy()
} }
private fun restoreVpnKillSwitch() {
with(autoTunnelStateFlow.value) {
if (settings.isVpnKillSwitchEnabled && tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE) {
killSwitchJob?.cancel()
val allowedIps = if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
}
}
}
private fun launchWatcherNotification(description: String = getString(R.string.monitoring_state_changes)) { private fun launchWatcherNotification(description: String = getString(R.string.monitoring_state_changes)) {
val notification = val notification =
notificationManager.createNotification( notificationManager.createNotification(
@@ -37,18 +37,19 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Start listening called for auto tunnel tile") Timber.d("Start listening called for auto tunnel tile")
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleScope.launch { lifecycleScope.launch {
serviceManager.autoTunnelActive.collect { serviceManager.autoTunnelActive.collect {
if (it) return@collect setActive() if (it) setActive() else setInactive()
setInactive()
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
appDataRepository.tunnels.flow.collect { appDataRepository.tunnels.flow.collect {
if (it.isEmpty()) { if (it.isEmpty()) {
setUnavailable() setUnavailable()
} else {
if (qsTile.state == Tile.STATE_ACTIVE) setInactive()
} }
} }
} }
@@ -10,12 +10,9 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -59,55 +56,18 @@ class TunnelControlTile : TileService(), LifecycleOwner {
} }
} }
private suspend fun updateTileState() { private fun updateTileState() = lifecycleScope.launch {
try { val tunnels = appDataRepository.tunnels.getAll()
val tunnels = appDataRepository.tunnels.getAll() if (tunnels.isEmpty()) return@launch setUnavailable()
if (tunnels.isEmpty()) { with(tunnelManager.activeTunnels.value) {
setUnavailable() if (isNotEmpty()) if (size == 1) {
return tunnels.firstOrNull { it.id == keys.first().id }?.let { return@launch updateTile(it.tunName, true) }
} else {
return@launch updateTile(getString(R.string.multiple), true)
} }
val activeTunnels = tunnelManager.activeTunnels.value
.filter { it.value.status.isUpOrStarting() }
when {
activeTunnels.isNotEmpty() -> {
val activeIds = activeTunnels.map { it.key.id }
// TODO improvements would be needed to make this work well with toggling multiple tunnels
// this would be better managed elsewhere
WireGuardAutoTunnel.setLastActiveTunnels(activeIds)
updateTileForActiveTunnels(activeTunnels)
}
else -> updateTileForLastActiveTunnels()
}
} catch (e: Exception) {
setUnavailable()
} }
} appDataRepository.getStartTunnelConfig()?.let {
updateTile(it.tunName, false)
private fun updateTileForActiveTunnels(activeTunnels: Map<TunnelConf, TunnelState>) {
val tileName = when (activeTunnels.size) {
1 -> activeTunnels.keys.first().tunName
else -> getString(R.string.multiple)
}
updateTile(tileName, true)
}
private suspend fun updateTileForLastActiveTunnels() {
val lastActiveIds = WireGuardAutoTunnel.getLastActiveTunnels()
when {
lastActiveIds.isEmpty() -> {
appDataRepository.getStartTunnelConfig()?.let { config ->
updateTile(config.tunName, false)
} ?: setUnavailable()
}
lastActiveIds.size > 1 -> updateTile(getString(R.string.multiple), false)
else -> {
val tunnelId = lastActiveIds.first()
appDataRepository.tunnels.getById(tunnelId)?.let { tunnel ->
updateTile(tunnel.tunName, false)
} ?: setUnavailable()
}
} }
} }
@@ -116,17 +76,8 @@ class TunnelControlTile : TileService(), LifecycleOwner {
unlockAndRun { unlockAndRun {
lifecycleScope.launch { lifecycleScope.launch {
if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel() if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
val lastActive = WireGuardAutoTunnel.getLastActiveTunnels() appDataRepository.getStartTunnelConfig()?.let {
if (lastActive.isEmpty()) { tunnelManager.startTunnel(it)
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
}
} else {
lastActive.forEach { id ->
appDataRepository.tunnels.getById(id)?.let {
tunnelManager.startTunnel(it)
}
}
} }
} }
} }
@@ -1,6 +1,11 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
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.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
@@ -10,7 +15,10 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -18,66 +26,88 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
abstract class BaseTunnel( abstract class BaseTunnel(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager, private val serviceManager: ServiceManager,
private val notificationManager: NotificationManager,
) : TunnelProvider { ) : TunnelProvider {
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap()) private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
private val tunThreads = ConcurrentHashMap<Int, Thread>()
override val activeTunnels = activeTuns.asStateFlow() override val activeTunnels = activeTuns.asStateFlow()
private val tunMutex = Mutex() private val isBounce = AtomicBoolean(false)
private val tunStatusMutex = Mutex()
private val isBouncing = AtomicBoolean(false) private val mutex = Mutex()
abstract suspend fun startBackend(tunnel: TunnelConf) abstract suspend fun startBackend(tunnel: TunnelConf)
abstract fun stopBackend(tunnel: TunnelConf) abstract fun stopBackend(tunnel: TunnelConf)
override suspend fun clearError(tunnelConf: TunnelConf) = updateTunnelStatus(tunnelConf, TunnelStatus.Down) private fun findActiveTunnel(id: Int): TunnelConf? = activeTuns.value.keys.find { it.id == id }
override fun hasVpnPermission(): Boolean { private fun isTunnelActive(id: Int) = (activeTuns.value.any { it.key.id == id })
return serviceManager.hasVpnPermission()
private fun handleBackendThrowable(throwable: Throwable) {
val backendError = when (throwable) {
is BackendException -> throwable.toBackendError()
is org.amnezia.awg.backend.BackendException -> throwable.toBackendError()
else -> BackendError.Unknown
}
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)
} else {
notificationManager.show(
NotificationManager.VPN_NOTIFICATION_ID,
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = StringValue.StringResource(R.string.tunne_start_failed_title),
description = message,
),
)
}
} }
protected suspend fun updateTunnelStatus(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) { private fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
tunStatusMutex.withLock { applicationScope.launch(ioDispatcher) {
activeTuns.update { current -> mutex.withLock {
val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf activeTuns.update { current ->
val existingState = current.getValueById(tunnelConf.id) ?: TunnelState() val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
val newState = state ?: existingState.status val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
if (newState == TunnelStatus.Down) { val newState = state ?: existingState.state
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN") if (newState == TunnelStatus.DOWN) {
current - originalConf Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
} else if (existingState.status == newState && stats == null) { current - originalConf
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState") } else if (existingState.state == newState && stats == null) {
current Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
} else { current
val updated = existingState.copy( } else {
status = newState, val updated = existingState.copy(
statistics = stats ?: existingState.statistics, state = newState,
) statistics = stats ?: existingState.statistics,
current + (originalConf to updated) )
current + (originalConf to updated)
}
} }
} }
} }
} }
private suspend fun stopActiveTunnels() { protected suspend fun stopActiveTunnels() {
activeTunnels.value.forEach { (config, state) -> activeTunnels.value.forEach { (config, state) ->
if (state.status.isUpOrStarting()) { if (state.state.isUp()) {
stopTunnel(config) stopTunnel(config)
delay(300) delay(300)
} }
@@ -90,49 +120,40 @@ abstract class BaseTunnel(
tunnelConf.setStateChangeCallback { state -> tunnelConf.setStateChangeCallback { state ->
Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}") Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}")
when (state) { when (state) {
is Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) } is Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) } is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
} }
serviceManager.updateTunnelTile() serviceManager.updateTunnelTile()
} }
tunnelConf.setTunnelStatsCallback {
val stats = getStatistics(tunnelConf)
updateTunnelState(tunnelConf, null, stats)
}
tunnelConf.setBounceTunnelCallback(::bounceTunnel)
} }
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) { override fun startTunnel(tunnelConf: TunnelConf) {
val stats = getStatistics(tunnel) applicationScope.launch(ioDispatcher) {
updateTunnelStatus(tunnel, null, stats) runCatching {
} if (isTunnelActive(tunnelConf.id)) return@launch
saveTunnelActiveState(tunnelConf, true)
override suspend fun startTunnel(tunnelConf: TunnelConf) { startTunnelInner(tunnelConf)
if (activeTuns.exists(tunnelConf.id) || tunThreads.containsKey(tunnelConf.id)) return }.onFailure { exception ->
// stop active tunnels if we are userspace Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace")
if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels() stopTunnel(tunnelConf)
tunMutex.withLock { handleBackendThrowable(exception)
// use thread to interrupt java backend if stuck (like in dns resolution) }.onSuccess {
tunThreads += tunnelConf.id to thread { Timber.i("Tunnel ${tunnelConf.id} started successfully")
runBlocking {
try {
Timber.d("Starting tunnel ${tunnelConf.id}...")
startTunnelInner(tunnelConf)
Timber.d("Started complete for tunnel ${tunnelConf.name}...")
} catch (e: BackendError) {
Timber.e(e, "Failed to start tunnel ${tunnelConf.name} userspace")
updateTunnelStatus(tunnelConf, TunnelStatus.Error(e))
} catch (e: InterruptedException) {
Timber.i("Tunnel start has been interrupted as ${tunnelConf.name} failed to start")
}
}
} }
} }
} }
private suspend fun startTunnelInner(tunnelConf: TunnelConf) { private suspend fun startTunnelInner(tunnelConf: TunnelConf) {
configureTunnelCallbacks(tunnelConf) mutex.withLock {
Timber.d("Started backend for tunnel ${tunnelConf.id}...") configureTunnelCallbacks(tunnelConf)
startBackend(tunnelConf) startBackend(tunnelConf)
updateTunnelStatus(tunnelConf, TunnelStatus.Up) if (!isBounce.get()) serviceManager.startTunnelForegroundService(tunnelConf)
Timber.d("DONE for tun ${tunnelConf.id}...") }
saveTunnelActiveState(tunnelConf, true)
serviceManager.startTunnelForegroundService()
} }
private suspend fun saveTunnelActiveState(tunnelConf: TunnelConf, active: Boolean) { private suspend fun saveTunnelActiveState(tunnelConf: TunnelConf, active: Boolean) {
@@ -140,50 +161,35 @@ abstract class BaseTunnel(
appDataRepository.tunnels.save(tunnelCopy) appDataRepository.tunnels.save(tunnelCopy)
} }
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) { override fun stopTunnel(tunnelConf: TunnelConf?) {
if (tunnelConf == null) return stopActiveTunnels() applicationScope.launch(ioDispatcher) {
tunMutex.withLock { runCatching {
try { if (tunnelConf == null) return@launch stopActiveTunnels()
val stuckStarting = activeTuns.isStarting(tunnelConf.id)
handleTunnelThreadCleanup(tunnelConf)
if (stuckStarting) return Timber.d("Stuck in starting, so just shutting down tunnel thread")
updateTunnelStatus(tunnelConf, TunnelStatus.Stopping(reason))
stopTunnelInner(tunnelConf) stopTunnelInner(tunnelConf)
} catch (e: BackendError) { }.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf.id}") Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
updateTunnelStatus(tunnelConf, TunnelStatus.Error(e))
} }
} }
} }
private suspend fun stopTunnelInner(tunnelConf: TunnelConf) { private suspend fun stopTunnelInner(tunnelConf: TunnelConf) {
val tunnel = activeTuns.findTunnel(tunnelConf.id) ?: return mutex.withLock {
stopBackend(tunnel) val tunnel = findActiveTunnel(tunnelConf.id) ?: return
saveTunnelActiveState(tunnelConf, false) saveTunnelActiveState(tunnelConf, false)
removeActiveTunnel(tunnel) stopBackend(tunnel)
handleServiceChangesOnStop() removeActiveTunnel(tunnel)
} // use latest tunnel
handleServiceChangesOnStop()
private suspend fun handleServiceChangesOnStop() { }
if (activeTuns.value.isEmpty() && !isBouncing.get()) return serviceManager.stopTunnelForegroundService() }
}
private fun handleServiceChangesOnStop() {
private suspend fun handleTunnelThreadCleanup(tunnel: TunnelConf) { if (activeTuns.value.isEmpty() && !isBounce.get()) return serviceManager.stopTunnelForegroundService()
Timber.d("Cleaning up thread for ${tunnel.name}") val nextActive = activeTuns.value.keys.firstOrNull()
try { if (nextActive != null) {
tunThreads[tunnel.id]?.let { Timber.d("Next active tunnel: ${nextActive.id}")
if (it.state != Thread.State.TERMINATED) { serviceManager.updateTunnelForegroundServiceNotification(nextActive)
it.interrupt()
updateTunnelStatus(tunnel, TunnelStatus.Down)
} else {
Timber.d("Thread already terminated")
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to stop tunnel thread for ${tunnel.name}")
} }
Timber.d("Removing thread for ${tunnel.name}")
tunThreads -= tunnel.id
} }
private fun removeActiveTunnel(tunnelConf: TunnelConf) { private fun removeActiveTunnel(tunnelConf: TunnelConf) {
@@ -192,12 +198,15 @@ abstract class BaseTunnel(
} }
} }
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) { override fun bounceTunnel(tunnelConf: TunnelConf) {
Timber.i("Bounce tunnel ${tunnelConf.name}") applicationScope.launch(ioDispatcher) {
isBouncing.set(true) Timber.i("Bounce tunnel ${tunnelConf.name}")
stopTunnel(tunnelConf, reason) isBounce.set(true)
startTunnel(tunnelConf) stopTunnel(tunnelConf)
isBouncing.set(false) delay(300)
startTunnel(tunnelConf)
isBounce.set(false)
}
} }
override suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet() override suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet()
@@ -1,16 +1,14 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import kotlinx.coroutines.flow.MutableStateFlow
fun Map<TunnelConf, TunnelState>.allDown(): Boolean { fun Map<TunnelConf, TunnelState>.allDown(): Boolean {
return this.all { it.value.status.isDown() } return this.all { it.value.state.isDown() }
} }
fun Map<TunnelConf, TunnelState>.hasActive(): Boolean { fun Map<TunnelConf, TunnelState>.hasActive(): Boolean {
return this.any { it.value.status.isUp() } return this.any { it.value.state.isUp() }
} }
fun Map<TunnelConf, TunnelState>.getValueById(id: Int): TunnelState? { fun Map<TunnelConf, TunnelState>.getValueById(id: Int): TunnelState? {
@@ -23,29 +21,5 @@ fun Map<TunnelConf, TunnelState>.getKeyById(id: Int): TunnelConf? {
} }
fun Map<TunnelConf, TunnelState>.isUp(tunnelConf: TunnelConf): Boolean { fun Map<TunnelConf, TunnelState>.isUp(tunnelConf: TunnelConf): Boolean {
return this.getValueById(tunnelConf.id)?.status?.isUp() ?: false return this.getValueById(tunnelConf.id)?.state?.isUp() ?: false
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.exists(id: Int): Boolean {
return this.value.any { it.key.id == id }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.isUp(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Up }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.isStarting(id: Int): Boolean {
return this.value.any { it.key.id == id && it.value.status == TunnelStatus.Starting }
}
fun MutableStateFlow<Map<TunnelConf, TunnelState>>.findTunnel(id: Int): TunnelConf? {
return this.value.keys.find { it.id == id }
}
private val URL_PATTERN = Regex(
"""^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}:[0-9]{1,5}$""",
)
fun String.isUrl(): Boolean {
return URL_PATTERN.matches(this)
} }
@@ -1,18 +1,16 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState 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.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import timber.log.Timber import timber.log.Timber
@@ -23,8 +21,9 @@ class KernelTunnel @Inject constructor(
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
serviceManager: ServiceManager, serviceManager: ServiceManager,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
private val backend: Backend, private val backend: Backend,
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) { ) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) {
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try { return try {
@@ -36,31 +35,18 @@ class KernelTunnel @Inject constructor(
} }
override suspend fun startBackend(tunnel: TunnelConf) { override suspend fun startBackend(tunnel: TunnelConf) {
try { backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
updateTunnelStatus(tunnel, TunnelStatus.Starting)
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
} }
override fun stopBackend(tunnel: TunnelConf) { override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.id} kernel") Timber.i("Stopping tunnel ${tunnel.id} kernel")
try { backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig())
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
} }
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) { override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.w("Not yet implemented for kernel") Timber.w("Not yet implemented for kernel")
} }
override fun getBackendState(): BackendState {
return BackendState.INACTIVE
}
override suspend fun runningTunnelNames(): Set<String> { override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames return backend.runningTunnelNames
} }
@@ -6,7 +6,6 @@ import com.zaneschepke.wireguardautotunnel.di.Kernel
import com.zaneschepke.wireguardautotunnel.di.Userspace import com.zaneschepke.wireguardautotunnel.di.Userspace
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState 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.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -57,38 +56,22 @@ class TunnelManager @Inject constructor(
initialValue = emptyMap(), initialValue = emptyMap(),
) )
override fun hasVpnPermission(): Boolean { override fun startTunnel(tunnelConf: TunnelConf) {
return userspaceTunnel.hasVpnPermission()
}
override suspend fun clearError(tunnelConf: TunnelConf) {
tunnelProviderFlow.value.clearError(tunnelConf)
}
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
tunnelProviderFlow.value.updateTunnelStatistics(tunnel)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
tunnelProviderFlow.value.startTunnel(tunnelConf) tunnelProviderFlow.value.startTunnel(tunnelConf)
} }
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) { override fun stopTunnel(tunnelConf: TunnelConf?) {
tunnelProviderFlow.value.stopTunnel(tunnelConf) tunnelProviderFlow.value.stopTunnel(tunnelConf)
} }
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) { override fun bounceTunnel(tunnelConf: TunnelConf) {
tunnelProviderFlow.value.bounceTunnel(tunnelConf) tunnelProviderFlow.value.bounceTunnel(tunnelConf)
} }
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) { override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
tunnelProviderFlow.value.setBackendState(backendState, allowedIps) tunnelProviderFlow.value.setBackendState(backendState, allowedIps)
} }
override fun getBackendState(): BackendState {
return tunnelProviderFlow.value.getBackendState()
}
override suspend fun runningTunnelNames(): Set<String> { override suspend fun runningTunnelNames(): Set<String> {
return tunnelProviderFlow.value.runningTunnelNames() return tunnelProviderFlow.value.runningTunnelNames()
} }
@@ -2,21 +2,16 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider { interface TunnelProvider {
suspend fun startTunnel(tunnelConf: TunnelConf) fun startTunnel(tunnelConf: TunnelConf)
suspend fun stopTunnel(tunnelConf: TunnelConf? = null, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER) fun stopTunnel(tunnelConf: TunnelConf? = null)
suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER) fun bounceTunnel(tunnelConf: TunnelConf)
fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
fun getBackendState(): BackendState
suspend fun runningTunnelNames(): Set<String> suspend fun runningTunnelNames(): Set<String>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>> val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>>
fun hasVpnPermission(): Boolean
suspend fun clearError(tunnelConf: TunnelConf)
suspend fun updateTunnelStatistics(tunnel: TunnelConf)
} }
@@ -1,95 +1,44 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState 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.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.amnezia.awg.backend.Backend import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.BackendException
import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.backend.Tunnel
import org.amnezia.awg.config.Config
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
class UserspaceTunnel @Inject constructor( class UserspaceTunnel @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
val serviceManager: ServiceManager, serviceManager: ServiceManager,
val appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
private val backend: Backend, private val backend: Backend,
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) { ) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) {
private var previousBackendState: Pair<BackendState, Boolean>? = null
override suspend fun startBackend(tunnel: TunnelConf) { override suspend fun startBackend(tunnel: TunnelConf) {
try { stopActiveTunnels()
updateTunnelStatus(tunnel, TunnelStatus.Starting) backend.setState(tunnel, Tunnel.State.UP, tunnel.toAmConfig())
val amConfig = tunnel.toAmConfig()
handleVpnKillSwitchWithDomainEndpoints(amConfig)
backend.setState(tunnel, Tunnel.State.UP, amConfig)
} catch (e: BackendException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw e.toBackendError()
}
} }
override fun stopBackend(tunnel: TunnelConf) { override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.name} userspace") Timber.i("Stopping tunnel ${tunnel.id} userspace")
try { backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig())
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig())
} catch (e: BackendException) {
Timber.e(e, "Failed to stop tunnel ${tunnel.id}")
throw e.toBackendError()
}
handlePreviouslyEnabledVpnKillSwitch()
} }
// stop vpn kill switch if we need to resolve DNS for peer endpoints override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
private suspend fun handleVpnKillSwitchWithDomainEndpoints(config: Config) {
if (config.peers.any { it.endpoint.getOrNull()?.toString()?.isUrl() == true } &&
backend.backendState.asBackendState() == BackendState.KILL_SWITCH_ACTIVE
) {
val bypassLan = appDataRepository.settings.get().isLanOnKillSwitchEnabled
previousBackendState = Pair(BackendState.KILL_SWITCH_ACTIVE, bypassLan)
setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
}
}
// restore vpn kill switch if needed
private fun handlePreviouslyEnabledVpnKillSwitch() {
// let auto tunnel handle this if it is active
if (!serviceManager.autoTunnelActive.value) {
previousBackendState?.let { (state, lanEnabled) ->
Timber.d("Restoring kill switch configuration")
val lan = if (lanEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
backend.setBackendState(state.asAmBackendState(), lan)
}
}
previousBackendState = null
}
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps") Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
try { backend.setBackendState(backendState.asAmBackendState(), allowedIps)
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
} catch (e: BackendException) {
throw e.toBackendError()
}
}
override fun getBackendState(): BackendState {
return backend.backendState.asBackendState()
} }
override suspend fun runningTunnelNames(): Set<String> { override suspend fun runningTunnelNames(): Set<String> {
@@ -6,6 +6,7 @@ import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
@@ -66,9 +67,10 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager, serviceManager: ServiceManager,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
backend: com.wireguard.android.backend.Backend, backend: com.wireguard.android.backend.Backend,
): TunnelProvider { ): TunnelProvider {
return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend) return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend)
} }
@Provides @Provides
@@ -79,9 +81,10 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager, serviceManager: ServiceManager,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
backend: Backend, backend: Backend,
): TunnelProvider { ): TunnelProvider {
return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend) return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend)
} }
@Provides @Provides
@@ -107,10 +110,9 @@ class TunnelModule {
fun provideServiceManager( fun provideServiceManager(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher, @IoDispatcher ioDispatcher: CoroutineDispatcher,
@MainDispatcher mainCoroutineDispatcher: CoroutineDispatcher,
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
): ServiceManager { ): ServiceManager {
return ServiceManager(context, ioDispatcher, applicationScope, mainCoroutineDispatcher, appDataRepository) return ServiceManager(context, ioDispatcher, applicationScope, appDataRepository)
} }
} }
@@ -32,28 +32,22 @@ data class TunnelConf(
val isIpv4Preferred: Boolean = true, val isIpv4Preferred: Boolean = true,
@Transient @Transient
private var stateChangeCallback: ((Any) -> Unit)? = null, private var stateChangeCallback: ((Any) -> Unit)? = null,
@Transient
private var tunnelStatsCallback: (() -> Unit)? = null,
@Transient
private var bounceTunnelCallback: ((TunnelConf) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel { ) : Tunnel, org.amnezia.awg.backend.Tunnel {
fun setStateChangeCallback(callback: (Any) -> Unit) { fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback stateChangeCallback = callback
} }
override fun equals(other: Any?): Boolean { fun setTunnelStatsCallback(callback: (() -> Unit)) {
if (this === other) return true tunnelStatsCallback = callback
if (other !is TunnelConf) return false
return id == other.id && tunName == other.tunName && wgQuick == other.wgQuick && amQuick == other.amQuick &&
isPrimaryTunnel == other.isPrimaryTunnel && isMobileDataTunnel == other.isMobileDataTunnel &&
isEthernetTunnel == other.isEthernetTunnel && isPingEnabled == other.isPingEnabled && pingIp == other.pingIp &&
pingCooldown == other.pingCooldown && pingInterval == other.pingInterval && tunnelNetworks == other.tunnelNetworks &&
isIpv4Preferred == other.isIpv4Preferred
} }
override fun hashCode(): Int { fun setBounceTunnelCallback(callback: (TunnelConf) -> Unit) {
var result = id bounceTunnelCallback = callback
result = 31 * result + tunName.hashCode()
result = 31 * result + wgQuick.hashCode()
result = 31 * result + amQuick.hashCode()
return result
} }
fun copyWithCallback( fun copyWithCallback(
@@ -78,18 +72,18 @@ data class TunnelConf(
isEthernetTunnel, isIpv4Preferred, isEthernetTunnel, isIpv4Preferred,
).apply { ).apply {
stateChangeCallback = this@TunnelConf.stateChangeCallback stateChangeCallback = this@TunnelConf.stateChangeCallback
// tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
// bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
} }
} }
// fun onUpdateStatistics() { fun onUpdateStatistics() {
// tunnelStatsCallback?.invoke() tunnelStatsCallback?.invoke()
// } }
//
// fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) { fun bounceTunnel(tunnelConf: TunnelConf) {
// bounceTunnelCallback?.invoke(tunnelConf, reason) bounceTunnelCallback?.invoke(tunnelConf)
// } }
fun toAmConfig(): org.amnezia.awg.config.Config { fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(amQuick.ifBlank { wgQuick }) return configFromAmQuick(amQuick.ifBlank { wgQuick })
@@ -1,24 +1,8 @@
package com.zaneschepke.wireguardautotunnel.domain.enums package com.zaneschepke.wireguardautotunnel.domain.enums
import com.zaneschepke.wireguardautotunnel.R sealed class BackendError() {
sealed class BackendError : Exception() {
data object DNS : BackendError() data object DNS : BackendError()
data object Unauthorized : BackendError() data object Unauthorized : BackendError()
data object Config : BackendError() data object Config : BackendError()
data object KernelModuleName : BackendError()
data object InvalidConfig : BackendError()
data object NotAuthorized : BackendError()
data object ServiceNotRunning : BackendError()
data object Unknown : BackendError() data object Unknown : BackendError()
fun toStringRes() = when (this) {
Config -> R.string.config_error
DNS -> R.string.dns_resolve_error
InvalidConfig -> R.string.invalid_config_error
KernelModuleName -> R.string.kernel_name_error
NotAuthorized, Unauthorized -> R.string.auth_error
ServiceNotRunning -> R.string.service_running_error
Unknown -> R.string.unknown_error
}
} }
@@ -1,31 +1,17 @@
package com.zaneschepke.wireguardautotunnel.domain.enums package com.zaneschepke.wireguardautotunnel.domain.enums
sealed class TunnelStatus { enum class TunnelStatus {
data class Error(val error: BackendError) : TunnelStatus() UP,
data object Up : TunnelStatus() DOWN,
data object Down : TunnelStatus() STARTING,
data class Stopping(val reason: StopReason) : TunnelStatus() STOPPING,
data object Starting : TunnelStatus() ;
enum class StopReason {
USER,
PING,
CONFIG_CHANGED,
}
fun isDown(): Boolean { fun isDown(): Boolean {
return this == Down return this == DOWN
} }
fun isUp(): Boolean { fun isUp(): Boolean {
return this == Up return this == UP
}
fun isUpOrStarting(): Boolean {
return this == Up || this == Starting
}
fun isDownOrStopping(): Boolean {
return this == Down || this is Stopping
} }
} }
@@ -4,7 +4,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
data class TunnelState( data class TunnelState(
val status: TunnelStatus = TunnelStatus.Down, val state: TunnelStatus = TunnelStatus.DOWN,
val backendState: BackendState = BackendState.INACTIVE, val backendState: BackendState = BackendState.INACTIVE,
val statistics: TunnelStatistics? = null, val statistics: TunnelStatistics? = null,
) )
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable @Composable
fun <T> DropdownSelector( fun <T> DropdownSelector(
@@ -40,7 +41,7 @@ fun <T> DropdownSelector(
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown)) Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
} }
DropdownMenu( DropdownMenu(
modifier = modifier.height(250.dp), modifier = modifier.height(250.dp.scaledHeight()),
scrollState = rememberScrollState(), scrollState = rememberScrollState(),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
expanded = isExpanded, expanded = isExpanded,
@@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
class NestedScrollListener(val onUp: () -> Unit, val onDown: () -> Unit) : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) onDown()
if (available.y > 1) onUp()
return Offset.Zero
}
}
@@ -0,0 +1,82 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun SearchBar(onQuery: (queryString: String) -> Unit) {
// Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) }
if (query.isEmpty()) {
showClearIcon = false
} else if (query.isNotEmpty()) {
showClearIcon = true
}
TextField(
value = query,
onValueChange = { onQueryChanged ->
// If user makes changes to text, immediately updated it.
query = onQueryChanged
onQuery(onQueryChanged)
},
leadingIcon = {
val icon = Icons.Rounded.Search
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
},
trailingIcon = {
if (showClearIcon) {
IconButton(onClick = { query = "" }) {
val icon = Icons.Rounded.Clear
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
}
}
},
maxLines = 1,
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier =
Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.label package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, leadingIcon: ImageVector? = null, description: String? = null) { fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, leadingIcon: ImageVector? = null, description: String? = null) {
@@ -51,22 +53,22 @@ fun IconSurfaceButton(title: String, onClick: () -> Unit, selected: Boolean, lea
Column( Column(
modifier = modifier =
Modifier Modifier
.padding(horizontal = 8.dp, vertical = 10.dp) .padding(horizontal = 8.dp.scaledWidth(), vertical = 10.dp.scaledHeight())
.padding(end = 16.dp).padding(start = 8.dp) .padding(end = 16.dp.scaledWidth()).padding(start = 8.dp.scaledWidth())
.fillMaxSize(), .fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
) { ) {
Row( Row(
verticalAlignment = Alignment.Companion.CenterVertically, verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp.scaledWidth()),
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy( horizontalArrangement = Arrangement.spacedBy(
16.dp, 16.dp.scaledWidth(),
), ),
verticalAlignment = Alignment.Companion.CenterVertically, verticalAlignment = Alignment.Companion.CenterVertically,
modifier = Modifier.padding(vertical = if (description == null) 10.dp else 0.dp), modifier = Modifier.padding(vertical = if (description == null) 10.dp.scaledHeight() else 0.dp),
) { ) {
leadingIcon?.let { leadingIcon?.let {
Icon( Icon(
@@ -7,13 +7,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable @Composable
fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) { fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) {
Switch( Switch(
checked, checked,
{ onClick(it) }, { onClick(it) },
modifier.scale((52.dp / 52.dp)), modifier.scale((52.dp.scaledHeight() / 52.dp)),
enabled = enabled, enabled = enabled,
colors = SwitchDefaults.colors().copy( colors = SwitchDefaults.colors().copy(
checkedThumbColor = MaterialTheme.colorScheme.background, checkedThumbColor = MaterialTheme.colorScheme.background,
@@ -19,9 +19,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun SelectionItemButton( fun SelectionItemButton(
@@ -30,21 +31,20 @@ fun SelectionItemButton(
trailing: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
ripple: Boolean = true, ripple: Boolean = true,
modifier: Modifier = Modifier,
) { ) {
Card( Card(
modifier = modifier =
modifier Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable( .clickable(
indication = if (ripple) ripple() else null, indication = if (ripple) ripple() else null,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
onClick = { onClick() }, onClick = { onClick() },
) )
.height(56.dp), .height(56.dp.scaledHeight()),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = Color.Transparent, containerColor = MaterialTheme.colorScheme.background,
), ),
) { ) {
Row( Row(
@@ -52,7 +52,7 @@ fun SelectionItemButton(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(end = 10.dp), .padding(end = 10.dp.scaledWidth()),
) { ) {
leading?.let { leading?.let {
it() it()
@@ -19,6 +19,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) { fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
@@ -36,12 +38,12 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.padding(start = 16.dp) .padding(start = 16.dp.scaledWidth())
.weight(4f, false) .weight(4f, false)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
@@ -58,8 +60,8 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = if (item.leadingIcon != null) 16.dp else 0.dp) .padding(start = if (item.leadingIcon != null) 16.dp.scaledWidth() else 0.dp)
.padding(vertical = if (item.description == null) 16.dp else 6.dp), .padding(vertical = if (item.description == null) 16.dp.scaledHeight() else 6.dp.scaledHeight()),
) { ) {
item.title() item.title()
item.description?.let { item.description?.let {
@@ -71,7 +73,7 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Box( Box(
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,
modifier = Modifier modifier = Modifier
.padding(end = 24.dp, start = 16.dp) .padding(end = 24.dp.scaledWidth(), start = 16.dp.scaledWidth())
.weight(1f), .weight(1f),
) { ) {
it() it()
@@ -35,9 +35,9 @@ fun ConfigurationTextBox(
singleLine = true, singleLine = true,
interactionSource = interactionSource, interactionSource = interactionSource,
onValueChange = { onValueChange(it) }, onValueChange = { onValueChange(it) },
label = { Text(label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium) }, label = { Text(label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium) },
maxLines = 1, maxLines = 1,
placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.bodyMedium) }, placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.labelLarge) },
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
trailingIcon = trailing, trailingIcon = trailing,
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun SubmitConfigurationTextBox( fun SubmitConfigurationTextBox(
@@ -58,7 +59,7 @@ fun SubmitConfigurationTextBox(
.padding( .padding(
top = 5.dp, top = 5.dp,
bottom = 10.dp, bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp), ).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
singleLine = true, singleLine = true,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
@@ -1,14 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
@@ -19,32 +15,23 @@ fun InfoDialog(
body: @Composable () -> Unit, body: @Composable () -> Unit,
confirmText: @Composable () -> Unit, confirmText: @Composable () -> Unit,
) { ) {
MaterialTheme( AlertDialog(
colorScheme = MaterialTheme.colorScheme.copy(), onDismissRequest = { onDismiss() },
) { confirmButton = {
Surface( TextButton(
color = MaterialTheme.colorScheme.surface, onClick = {
tonalElevation = 0.dp, onAttest()
) {
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(onClick = { onAttest() }) {
confirmText()
}
}, },
dismissButton = { ) {
TextButton(onClick = { onDismiss() }) { confirmText()
Text(text = stringResource(R.string.cancel)) }
} },
}, dismissButton = {
containerColor = MaterialTheme.colorScheme.surface, TextButton(onClick = { onDismiss() }) {
title = { title() }, Text(text = stringResource(R.string.cancel))
text = { body() }, }
properties = DialogProperties( },
usePlatformDefaultWidth = true, title = { title() },
), text = { body() },
) )
}
}
} }
@@ -6,7 +6,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
@Composable @Composable
fun GroupLabel(title: String) { fun GroupLabel(title: String) {
@@ -17,7 +16,6 @@ fun GroupLabel(title: String) {
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
} }
@@ -1,93 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
@Composable
fun BottomBarTabs(tabs: List<BottomNavItem>, selectedTabIndex: Int, isChildRoute: Boolean, onTabSelected: (BottomNavItem) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp)
.padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEachIndexed { index, tab ->
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(Color.Transparent)
.pointerInput(Unit) {
detectTapGestures {
if (index == selectedTabIndex && !isChildRoute) return@detectTapGestures
tab.onClick.invoke()
onTabSelected(tab)
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val animatedColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "animatedColor",
)
val color = if (selectedTabIndex == index) animatedColor else MaterialTheme.colorScheme.onSurface
if (tab.active) {
BadgedBox(
badge = {
Badge(
modifier = Modifier
.offset(x = 8.dp, y = ((-8).dp))
.size(6.dp),
containerColor = SilverTree,
)
},
) {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
} else {
Icon(
imageVector = tab.icon,
contentDescription = tab.name,
tint = color,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
@@ -0,0 +1,73 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
showBottomBar = bottomNavItems.any {
navBackStackEntry?.isCurrentRoute(it.route::class) == true
}
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
bottomNavItems.forEachIndexed { index, item ->
val selected = navBackStackEntry.isCurrentRoute(item.route::class)
NavigationBarItem(
selected = selected,
onClick = {
if (selected) return@NavigationBarItem
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
}
},
label = {
Text(
text = item.name,
style = MaterialTheme.typography.labelMedium,
)
},
icon = {
Icon(
imageVector = item.icon,
contentDescription = "${item.name} Icon",
)
},
colors = NavigationBarItemDefaults.colors().copy(
selectedIndicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
unselectedIconColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
),
)
}
}
}
}
@@ -7,6 +7,4 @@ data class BottomNavItem(
val name: String, val name: String,
val route: Route, val route: Route,
val icon: ImageVector, val icon: ImageVector,
val onClick: () -> Unit,
val active: Boolean = false,
) )
@@ -1,133 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
@Composable
fun CustomBottomNavbar(tabs: List<BottomNavItem>, navBarState: NavBarState) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
var isChildRoute by remember { mutableStateOf(false) }
LaunchedEffect(tabs) {
}
when (navBarState.route) {
Route.Main -> {
selectedTabIndex = 0
isChildRoute = false
}
Route.AutoTunnel -> {
selectedTabIndex = 1
isChildRoute = false
}
Route.Settings -> {
selectedTabIndex = 2
isChildRoute = false
}
Route.Support -> {
selectedTabIndex = 3
isChildRoute = false
}
else -> isChildRoute = true
}
val systemBars = WindowInsets.systemBars
val bottomPadding = with(LocalDensity.current) {
systemBars.getBottom(this).toDp()
}
val navHeight = 64.dp + bottomPadding
Box(
modifier = Modifier
.fillMaxWidth()
.height(navHeight)
.background(Color.Transparent),
) {
BottomBarTabs(
tabs = tabs,
selectedTabIndex = selectedTabIndex,
isChildRoute = isChildRoute,
onTabSelected = {
selectedTabIndex = tabs.indexOf(it)
},
)
val animatedSelectedTabIndex by animateFloatAsState(
targetValue = selectedTabIndex.toFloat(),
label = "animatedSelectedTabIndex",
animationSpec = spring(
stiffness = Spring.StiffnessLow,
dampingRatio = Spring.DampingRatioLowBouncy,
),
)
val animatedColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.primary,
label = "animatedColor",
animationSpec = spring(
stiffness = Spring.StiffnessLow,
),
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(navHeight),
) {
val path = Path().apply {
addRoundRect(RoundRect(size.toRect(), CornerRadius(size.height)))
}
val length = PathMeasure().apply { setPath(path, false) }.length
val tabWidth = size.width / tabs.size
drawPath(
path,
brush = Brush.horizontalGradient(
colors = listOf(
animatedColor.copy(alpha = 0f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 1f),
animatedColor.copy(alpha = 0f),
),
startX = tabWidth * animatedSelectedTabIndex,
endX = tabWidth * (animatedSelectedTabIndex + 1),
),
style = Stroke(
width = 4f,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(length / 2, length),
),
),
)
}
}
}
@@ -1,47 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DynamicTopAppBar(navBarState: NavBarState, modifier: Modifier = Modifier) {
TopAppBar(
modifier = modifier,
colors = TopAppBarDefaults.topAppBarColors().copy(Color.Transparent),
title = {
AnimatedVisibility(
visible = navBarState.showTop,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
Box(modifier = Modifier.padding(start = 10.dp)) {
navBarState.topTitle?.invoke()
}
}
},
actions = {
AnimatedVisibility(
visible = navBarState.showTop,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
Box(modifier = Modifier.padding(end = 10.dp)) {
navBarState.topTrailing?.invoke()
}
}
},
)
}
@@ -1,217 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Brick
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
data class NavBarState(
val showTop: Boolean = true,
val showBottom: Boolean = true,
val topTitle: @Composable (() -> Unit)? = null,
val topTrailing: @Composable (() -> Unit)? = null,
val route: Route? = null,
)
@Composable
fun currentNavBackStackEntryAsNavBarState(
navController: NavController,
backStackEntry: NavBackStackEntry?,
viewModel: AppViewModel,
uiState: AppUiState,
): State<NavBarState> {
return produceState(initialValue = NavBarState(), key1 = backStackEntry, key2 = uiState) {
value = when {
backStackEntry.isCurrentRoute(Route.Main::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.tunnels)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Add
Icon(icon, stringResource(R.string.add_tunnel), modifier = Modifier.size(iconSize))
}
},
route = Route.Main,
)
}
backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.auto_tunnel)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) },
) {
val (icon, description, color) = if (uiState.appSettings.isAutoTunnelEnabled) {
Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick)
} else {
Triple(Icons.Rounded.PlayArrow, R.string.start_auto, SilverTree)
}
Icon(icon, stringResource(description), tint = color, modifier = Modifier.size(iconSize))
}
},
route = Route.AutoTunnel,
)
}
backStackEntry.isCurrentRoute(Route.AutoTunnelAdvanced::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.advanced_settings)) },
route = Route.AutoTunnelAdvanced,
)
}
backStackEntry.isCurrentRoute(Route.Settings::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.settings)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Menu
Icon(icon, stringResource(R.string.quick_actions), modifier = Modifier.size(iconSize))
}
},
route = Route.Settings,
)
}
backStackEntry.isCurrentRoute(Route.KillSwitch::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
}
backStackEntry.isCurrentRoute(Route.Appearance::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
}
backStackEntry.isCurrentRoute(Route.Language::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.language)) },
route = Route.Language,
)
}
backStackEntry.isCurrentRoute(Route.Display::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
}
backStackEntry.isCurrentRoute(Route.Logs::class) -> {
NavBarState(
showTop = true, showBottom = false,
{ Text(stringResource(R.string.logs)) },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
) {
val icon = Icons.Rounded.Menu
Icon(icon, stringResource(R.string.quick_actions), modifier = Modifier.size(iconSize))
}
},
route = Route.Logs,
)
}
backStackEntry.isCurrentRoute(Route.TunnelOptions::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelOptions>()
val tunnel = uiState.tunnels.find { it.id == args?.id }
NavBarState(
showTop = true, showBottom = true,
{ tunnel?.name?.let { Text(it) } },
{
IconButton(
onClick = { tunnel?.id?.let { navController.navigate(Route.Config(id = it)) } },
) {
val icon = Icons.Rounded.Edit
Icon(icon, stringResource(R.string.edit_tunnel), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.TunnelOptions(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.SplitTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.SplitTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) },
) {
val icon = Icons.Rounded.Save
Icon(icon, stringResource(R.string.save), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.SplitTunnel(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.Config::class) -> {
val args = backStackEntry?.toRoute<Route.Config>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
{
IconButton(
onClick = { viewModel.handleEvent(AppEvent.InvokeScreenAction) },
) {
val icon = Icons.Rounded.Save
Icon(icon, stringResource(R.string.save), modifier = Modifier.size(iconSize))
}
},
route = args?.let { Route.Config(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.TunnelAutoTunnel::class) -> {
val args = backStackEntry?.toRoute<Route.TunnelAutoTunnel>()
val name = uiState.tunnels.find { it.id == args?.id }?.name
NavBarState(
showTop = true, showBottom = true,
{ name?.let { Text(it) } },
route = args?.let { Route.TunnelAutoTunnel(it.id) },
)
}
backStackEntry.isCurrentRoute(Route.Support::class) -> {
NavBarState(
showTop = true, showBottom = true,
{ Text(stringResource(R.string.support)) },
route = Route.Support,
)
}
else -> NavBarState(showTop = false, showBottom = false)
}
}
}
@@ -0,0 +1,37 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}, showBack: Boolean = true) {
val navController = LocalNavController.current
CenterAlignedTopAppBar(
title = {
Text(title)
},
navigationIcon = {
if (showBack) {
IconButton(onClick = {
navController.popBackStack()
}) {
val icon = Icons.AutoMirrored.Outlined.ArrowBack
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
}
},
actions = {
trailing()
},
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
@Composable @Composable
@@ -0,0 +1,38 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
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.platform.LocalContext
@Composable
inline fun <T> withVpnPermission(crossinline onSuccess: (t: T) -> Unit): (t: T) -> Unit {
val context = LocalContext.current
var showVpnPermissionDialog by remember { mutableStateOf(false) }
val vpnActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true
},
)
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
return {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnActivity.launch(intent)
} else {
onSuccess(it)
}
}
}
@@ -0,0 +1,35 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.extensions.isBatteryOptimizationsDisabled
@Composable
inline fun withIgnoreBatteryOpt(ignore: Boolean, crossinline callback: () -> Unit): () -> Unit {
val context = LocalContext.current
val batteryActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
// we only ask once
callback()
}
return {
if (ignore || context.isBatteryOptimizationsDisabled()) {
callback()
} else {
batteryActivity.launch(
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
},
)
}
}
}
@@ -9,7 +9,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) { fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
@@ -19,20 +18,33 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val isBiometricAvailable = val isBiometricAvailable =
remember { remember {
when (bio) { when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError(context.getString(R.string.bio_not_created)) onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false false
} }
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> { BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError(context.getString(R.string.bio_update_required)) onError("Biometric security update required")
false false
} }
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, BiometricManager.BIOMETRIC_STATUS_UNKNOWN, BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, onError("Biometrics not supported")
-> { false
onError(context.getString(R.string.bio_not_supported)) }
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false false
} }
@@ -46,8 +58,8 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val promptInfo = val promptInfo =
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle(context.getString(R.string.bio_auth_title)) .setTitle("Biometric Authentication")
.setSubtitle(context.getString(R.string.bio_subtitle)) .setSubtitle("Log in using your biometric credential")
.build() .build()
val biometricPrompt = val biometricPrompt =
@@ -0,0 +1,108 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.StringValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlin.coroutines.EmptyCoroutineContext
private val LocalSnackbarController = staticCompositionLocalOf {
SnackbarController(
host = SnackbarHostState(),
scope = CoroutineScope(EmptyCoroutineContext),
)
}
private val channel = Channel<SnackbarChannelMessage>(capacity = 1)
@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
val snackHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val snackController = remember(scope) { SnackbarController(snackHostState, scope) }
val context = LocalContext.current
DisposableEffect(snackController, scope) {
val job = scope.launch {
for (payload in channel) {
snackController.showMessage(
message = payload.message.asString(context),
duration = payload.duration,
action = payload.action,
)
}
}
onDispose {
job.cancel()
}
}
CompositionLocalProvider(LocalSnackbarController provides snackController) {
content(
snackHostState,
)
}
}
@Immutable
class SnackbarController(
private val host: SnackbarHostState,
private val scope: CoroutineScope,
) {
companion object {
val current
@Composable
@ReadOnlyComposable
get() = LocalSnackbarController.current
fun showMessage(message: StringValue, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
channel.trySend(
SnackbarChannelMessage(
message = message,
duration = duration,
action = action,
),
)
}
}
fun showMessage(message: String, action: SnackbarAction? = null, duration: SnackbarDuration = SnackbarDuration.Short) {
scope.launch {
/**
* note: uncomment this line if you want snackbar to be displayed immediately,
* rather than being enqueued and waiting [duration] * current_queue_size
*/
host.currentSnackbarData?.dismiss()
val result =
host.showSnackbar(
message = message,
actionLabel = action?.title,
duration = duration,
)
if (result == SnackbarResult.ActionPerformed) {
action?.onActionPress?.invoke()
}
}
}
}
data class SnackbarChannelMessage(
val message: StringValue,
val action: SnackbarAction?,
val duration: SnackbarDuration = SnackbarDuration.Short,
)
data class SnackbarAction(val title: String, val onActionPress: () -> Unit)
@@ -0,0 +1,20 @@
package com.zaneschepke.wireguardautotunnel.ui.common.text
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun SectionTitle(title: String, padding: Dp) {
Text(
title,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
)
}
@@ -1,109 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel
import android.Manifest
import android.os.Build
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fineLocationState.status.isGranted
}
fun isWifiNameReadable(): Boolean {
return when {
!isBackgroundLocationGranted || !fineLocationState.status.isGranted -> {
showLocationDialog = true
false
}
!context.isLocationServicesEnabled() -> {
showLocationServicesAlertDialog = true
false
}
else -> true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (context.isRunningOnTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted()
} else {
val backgroundLocationState = rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted = backgroundLocationState.status.isGranted
}
}
LaunchedEffect(uiState.appSettings.trustedNetworkSSIDs) {
currentText = ""
}
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showLocationServicesAlertDialog = false },
onAttest = { showLocationServicesAlertDialog = false },
)
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = WifiTunnelingItems(uiState, viewModel, currentText, { currentText = it }, { isWifiNameReadable() }),
)
SurfaceSelectionGroupButton(
items = NetworkTunnelingItems(uiState, viewModel),
)
SurfaceSelectionGroupButton(
items = listOf(AdvancedSettingsItem()),
)
}
}
@@ -1,37 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components.DebounceDelaySelector
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun AdvancedScreen(appUiState: AppUiState) {
val appViewModel: AppViewModel = hiltViewModel()
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
DebounceDelaySelector(
currentDelay = appUiState.appSettings.debounceDelaySeconds,
onEvent = appViewModel::handleEvent,
)
}
}
@@ -1,31 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
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.navigation.LocalNavController
@Composable
fun AdvancedSettingsItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Settings,
title = {
Text(
stringResource(R.string.advanced_settings),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton { navController.navigate(Route.AutoTunnelAdvanced) }
},
onClick = { navController.navigate(Route.AutoTunnelAdvanced) },
)
}
@@ -1,98 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AirplanemodeActive
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<SelectionItem> {
return listOf(
SelectionItem(
leadingIcon = Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
)
},
description = {
val cellularActive = remember(uiState.networkStatus) { uiState.networkStatus?.cellularConnected ?: false }
Text(
text = if (cellularActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
),
SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
)
},
description = {
val ethernetActive = remember(uiState.networkStatus) { uiState.networkStatus?.ethernetConnected ?: false }
Text(
text = if (ethernetActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
),
SelectionItem(
leadingIcon = Icons.Outlined.AirplanemodeActive,
title = {
Text(
stringResource(R.string.stop_on_no_internet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.stop_on_internet_loss),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
),
)
}
@@ -1,196 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelingItems(
uiState: AppUiState,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
isWifiNameReadable: () -> Boolean,
): List<SelectionItem> {
val context = LocalContext.current
val baseItems = listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Wifi,
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnWifiEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
)
},
description = {
val wifiName by remember(uiState.networkStatus) {
derivedStateOf {
(uiState.networkStatus as? NetworkStatus.Connected)
?.takeIf { it.wifiConnected }
?.wifiSsid
}
}
Text(
text = wifiName?.let { stringResource(R.string.wifi_name_template, it) } ?: stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
),
SelectionItem(
leadingIcon = Icons.Outlined.Code,
title = {
Text(
stringResource(R.string.wifi_name_via_shell),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.use_root_shell_for_wifi),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWifiNameByShellEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
),
)
return if (uiState.appSettings.isTunnelOnWifiEnabled) {
baseItems + listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Filter1,
title = {
Text(
stringResource(R.string.use_wildcards),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
LearnMoreLinkLabel({ context.openWebUrl(it) }, stringResource(R.string.docs_wildcards))
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWildcardsEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
),
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
.padding(vertical = 6.dp),
) {
Text(
stringResource(R.string.trusted_wifi_names),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
uiState.appSettings.trustedNetworkSSIDs,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
if (uiState.appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
}
},
onValueChange = onTextChange,
supporting = { if (uiState.appSettings.isWildcardsEnabled) WildcardsLabel() },
)
},
),
SelectionItem(
leadingIcon = Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_off),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = uiState.appSettings.isVpnKillSwitchEnabled,
checked = uiState.appSettings.isDisableKillSwitchOnTrustedEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
),
)
} else {
baseItems
}
}
@@ -4,61 +4,83 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route 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.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
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.AddTunnelFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) { fun MainScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val snackbar = SnackbarController.current
var showBottomSheet by remember { mutableStateOf(false) }
var isFabVisible by rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConf?>(null) }
var showUrlImportDialog by remember { mutableStateOf(false) } var showUrlImportDialog by remember { mutableStateOf(false) }
val isRunningOnTv = remember { context.isRunningOnTv() }
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 tunnelFileImportResultLauncher = rememberFileImportLauncherForResult( val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
onNoFileExplorer = { viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_no_file_explorer))) }, onNoFileExplorer = { snackbar.showMessage(context.getString(R.string.error_no_file_explorer)) },
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) }, onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
) )
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) { if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.camera_permission_required)),
)
return@rememberLauncherForActivityResult
}
navController.navigate(Route.Scanner) navController.navigate(Route.Scanner)
} }
if (showDeleteTunnelAlertDialog && appViewState.selectedTunnel != null) { val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
}
if (showDeleteTunnelAlertDialog && selectedTunnel != null) {
InfoDialog( InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false }, onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = { onAttest = {
appViewState.selectedTunnel.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) } selectedTunnel?.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
showDeleteTunnelAlertDialog = false showDeleteTunnelAlertDialog = false
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null)) selectedTunnel = null
}, },
title = { Text(text = stringResource(R.string.delete_tunnel)) }, title = { Text(text = stringResource(R.string.delete_tunnel)) },
body = { Text(text = stringResource(R.string.delete_tunnel_message)) }, body = { Text(text = stringResource(R.string.delete_tunnel_message)) },
@@ -66,46 +88,66 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
) )
} }
TunnelImportSheet( Scaffold(
appViewState.showBottomSheet, floatingActionButtonPosition = FabPosition.End,
onDismiss = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) }, floatingActionButton = {
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) }, if (!isRunningOnTv) {
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) }, AddTunnelFab(
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } }, isVisible = isFabVisible,
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) }, onClick = { showBottomSheet = true },
onUrlClick = { showUrlImportDialog = true }, )
) }
},
topBar = {
if (isRunningOnTv) {
AddTunnelFab(
isVisible = isFabVisible,
isTv = true,
onClick = { showBottomSheet = true },
)
}
},
) { padding ->
TunnelImportSheet(
showBottomSheet,
onDismiss = { showBottomSheet = false },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } },
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) },
onUrlClick = { showUrlImportDialog = true },
)
if (showUrlImportDialog) { if (showUrlImportDialog) {
UrlImportDialog( UrlImportDialog(
onDismiss = { showUrlImportDialog = false }, onDismiss = { showUrlImportDialog = false },
onConfirm = { url -> onConfirm = { url ->
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url)) viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false 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()),
) )
} }
TunnelList(
appUiState = appUiState,
activeTunnels = appUiState.activeTunnels,
selectedTunnel = appViewState.selectedTunnel,
onSetSelectedTunnel = { viewModel.handleEvent(AppEvent.SetSelectedTunnel(it)) },
onDeleteTunnel = {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(it))
showDeleteTunnelAlertDialog = true
},
onToggleTunnel = { tunnel, checked ->
if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
},
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = {
viewModel.handleEvent(AppEvent.CopyTunnel(it))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
},
modifier = Modifier
.fillMaxSize()
.padding(top = 12.dp, bottom = 24.dp).padding(horizontal = 12.dp),
viewModel = viewModel,
)
} }
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.pin package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -8,6 +8,7 @@ import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController 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.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@@ -18,6 +19,7 @@ import xyz.teamgravity.pin_lock_compose.PinLock
fun PinLockScreen(viewModel: AppViewModel) { fun PinLockScreen(viewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val snackbar = SnackbarController.current
PinLock( PinLock(
title = { pinExists -> title = { pinExists ->
Text( Text(
@@ -47,12 +49,15 @@ fun PinLockScreen(viewModel: AppViewModel) {
}, },
onPinIncorrect = { onPinIncorrect = {
// pin is incorrect, show error // pin is incorrect, show error
viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.incorrect_pin))) snackbar.showMessage(
StringValue.StringResource(R.string.incorrect_pin).asString(context),
)
}, },
onPinCreated = { onPinCreated = {
// pin created for the first time, navigate or hide pin lock // pin created for the first time, navigate or hide pin lock
viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.pin_created))) snackbar.showMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
viewModel.handleEvent(AppEvent.TogglePinLock) viewModel.handleEvent(AppEvent.TogglePinLock)
}, },
) )
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.app.Activity import android.app.Activity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -0,0 +1,175 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet
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.LaunchedEffect
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.domain.entity.TunnelConf
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.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
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.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) { 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(
buildList {
addAll(
listOf(
SelectionItem(
Icons.Outlined.PhoneAndroid,
title = {
Text(
stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isMobileDataTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isEthernetTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
),
),
)
add(
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(4f, false)
.fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp.scaledWidth())
.padding(vertical = 6.dp.scaledHeight()),
) {
Text(
stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
tunnelConf.tunnelNetworks,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = { viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf)) },
onValueChange = { currentText = it },
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
),
)
},
)
}
}
}
@@ -0,0 +1,247 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.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.KeyboardType
import androidx.compose.ui.unit.dp
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.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.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Star,
title = {
Text(
stringResource(R.string.primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isPrimaryTunnel,
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.Bolt,
title = {
Text(
stringResource(R.string.auto_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.tunnel_specific_settings),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Edit,
title = {
Text(
stringResource(R.string.edit_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.Config(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.Config(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Dns,
title = {
Text(
stringResource(R.string.server_ipv4),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.prefer_ipv4),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isIpv4Preferred,
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
),
SelectionItem(
Icons.AutoMirrored.Outlined.CallSplit,
title = {
Text(
stringResource(R.string.splt_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.SplitTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) }
},
),
),
)
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
enabled = !appUiState.activeTunnels.isUp(tunnelConf),
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
),
)
if (tunnelConf.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConf.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { error -> !error.isNullOrBlank() && !error.isValidIpv4orIpv6Address() },
onSubmit = { ip ->
viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip))
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } == true
}
SubmitConfigurationTextBox(
tunnelConf.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = { interval ->
viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval))
},
)
SubmitConfigurationTextBox(
tunnelConf.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
)
},
),
)
}
},
)
}
}
}
@@ -1,51 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.EthernetTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = buildList {
add(MobileDataTunnelItem(tunnelConf, viewModel))
add(EthernetTunnelItem(tunnelConf, viewModel))
add(WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) { currentText = it })
},
)
}
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun EthernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
text = stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isEthernetTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.PhoneAndroid,
title = {
Text(
text = stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isMobileDataTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
}
@@ -1,76 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.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.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Security
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.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelItem(
tunnelConf: TunnelConf,
appSettings: AppSettings,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
): SelectionItem {
return SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Icon(
imageVector = Icons.Outlined.Security,
contentDescription = stringResource(R.string.use_tunnel_on_wifi_name),
modifier = Modifier.size(iconSize),
)
Text(
text = stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.padding(start = 16.dp),
)
}
},
description = {
TrustedNetworkTextBox(
trustedNetworks = tunnelConf.tunnelNetworks,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = {
viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf))
onTextChange("") // Reset the text field after saving
},
onValueChange = onTextChange,
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
)
}
@@ -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,
)
}
}
@@ -0,0 +1,57 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
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.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
@Composable
fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) {
val context = LocalContext.current
ExpandingRowListItem(
leading = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier =
Modifier
.size(16.dp).scale(1.5f),
tint =
if (!appUiState.autoTunnelActive) {
Color.Gray
} else {
SilverTree
},
)
},
text = stringResource(R.string.auto_tunneling),
trailing = {
ScaledSwitch(
appUiState.appSettings.isAutoTunnelEnabled,
onClick = {
onToggle()
},
)
},
onClick = {
if (context.isRunningOnTv()) {
onToggle()
}
},
isExpanded = false,
)
}
@@ -13,7 +13,6 @@ import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -42,7 +41,6 @@ fun TunnelImportSheet(
val context = LocalContext.current val context = LocalContext.current
if (show) { if (show) {
ModalBottomSheet( ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { onDismissRequest = {
onDismiss() onDismiss()
}, },
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -11,6 +12,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -19,7 +22,6 @@ import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import java.text.Collator import java.text.Collator
import java.util.* import java.util.*
@@ -29,13 +31,14 @@ fun TunnelList(
appUiState: AppUiState, appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>, activeTunnels: Map<TunnelConf, TunnelState>,
selectedTunnel: TunnelConf?, selectedTunnel: TunnelConf?,
onSetSelectedTunnel: (TunnelConf?) -> Unit, onTunnelSelected: (TunnelConf) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit, onDeleteTunnel: (TunnelConf) -> Unit,
onToggleAutoTunnel: () -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit, onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit, onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit, onCopyTunnel: (TunnelConf) -> Unit,
nestedScrollConnection: NestedScrollConnection,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AppViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault()) val collator = Collator.getInstance(Locale.getDefault())
@@ -49,7 +52,9 @@ fun TunnelList(
modifier = modifier modifier = modifier
.pointerInput(Unit) { .pointerInput(Unit) {
if (appUiState.tunnels.isEmpty()) return@pointerInput if (appUiState.tunnels.isEmpty()) return@pointerInput
}.overscroll(ScrollableDefaults.overscrollEffect()), }
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, appUiState.tunnels.count()), state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true, userScrollEnabled = true,
reverseLayout = false, reverseLayout = false,
@@ -59,21 +64,24 @@ fun TunnelList(
item { item {
GettingStartedLabel(onClick = { context.openWebUrl(it) }) GettingStartedLabel(onClick = { context.openWebUrl(it) })
} }
} else {
item {
AutoTunnelRowItem(appUiState, onToggleAutoTunnel)
}
} }
items(sortedTunnels, key = { it.id }) { tunnel -> items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState() val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem( TunnelRowItem(
isActive = tunnelState.status.isUpOrStarting(), isActive = tunnel.isActive,
expanded = appUiState.generalState.isTunnelStatsExpanded, expanded = appUiState.generalState.isTunnelStatsExpanded,
isSelected = selectedTunnel?.id == tunnel.id, isSelected = selectedTunnel?.id == tunnel.id,
tunnel = tunnel, tunnel = tunnel,
tunnelState = tunnelState, tunnelState = tunnelState,
onSetSelectedTunnel = { onSetSelectedTunnel(it) }, onHold = { onTunnelSelected(tunnel) },
onClick = onExpandStats, onClick = onExpandStats,
onCopy = { onCopyTunnel(tunnel) }, onCopy = { onCopyTunnel(tunnel) },
onDelete = { onDeleteTunnel(tunnel) }, onDelete = { onDeleteTunnel(tunnel) },
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) }, onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
viewModel = viewModel,
) )
} }
} }
@@ -1,8 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
@@ -24,7 +23,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -33,11 +31,9 @@ import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun TunnelRowItem( fun TunnelRowItem(
@@ -46,47 +42,46 @@ fun TunnelRowItem(
isSelected: Boolean, isSelected: Boolean,
tunnel: TunnelConf, tunnel: TunnelConf,
tunnelState: TunnelState, tunnelState: TunnelState,
onSetSelectedTunnel: (TunnelConf?) -> Unit, onHold: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onCopy: () -> Unit, onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSwitchClick: (Boolean) -> Unit, onSwitchClick: (Boolean) -> Unit,
viewModel: AppViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val snackbar = SnackbarController.current
val itemFocusRequester = remember { FocusRequester() } val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv() val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor() val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val leadingIcon = when {
val (leadingIcon, size) = when { tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp) tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp) tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp) else -> Icons.Rounded.Circle
else -> Pair(Icons.Rounded.Circle, 14.dp)
} }
ExpandingRowListItem( ExpandingRowListItem(
leading = { leading = {
Icon( Icon(
leadingIcon, leadingIcon,
stringResource(R.string.status), leadingIcon.name,
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.size(size), modifier = Modifier.size(16.dp),
) )
}, },
text = tunnel.tunName, text = tunnel.tunName,
onHold = { onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onSetSelectedTunnel(tunnel) onHold()
}, },
onClick = { onClick = {
if (!isTv) { if (!isTv) {
if (isActive) onClick() if (isActive) onClick()
} else { } else {
onSetSelectedTunnel(tunnel) onHold()
itemFocusRequester.requestFocus() itemFocusRequester.requestFocus()
} }
}, },
@@ -94,116 +89,45 @@ fun TunnelRowItem(
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) }, expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
trailing = { trailing = {
if (isSelected && !isTv) { if (isSelected && !isTv) {
Row( Row {
modifier = Modifier.fillMaxWidth(), IconButton(onClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) }) {
horizontalArrangement = Arrangement.SpaceEvenly, Icon(Icons.Rounded.Settings, "Settings")
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onSetSelectedTunnel(null)
navController.navigate(Route.TunnelOptions(tunnel.id))
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
} }
IconButton( IconButton(modifier = Modifier.focusable(), onClick = onCopy) {
modifier = Modifier.weight(1f), Icon(Icons.Rounded.CopyAll, "Copy")
onClick = {
onCopy()
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
} }
IconButton( IconButton(modifier = Modifier.focusable(), enabled = !isActive, onClick = onDelete) {
modifier = Modifier.weight(1f), Icon(Icons.Rounded.Delete, "Delete")
enabled = !isActive,
onClick = { onDelete() },
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
} }
} }
} else if (isTv) { } else if (isTv) {
Row( Row {
modifier = Modifier.fillMaxWidth(), IconButton(onClick = {
horizontalArrangement = Arrangement.SpaceEvenly, onHold()
) { navController.navigate(Route.TunnelOptions(tunnel.id))
IconButton( }) {
modifier = Modifier.weight(1f), Icon(Icons.Rounded.Settings, "Settings")
onClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
} }
IconButton( IconButton(onClick = {
modifier = Modifier.weight(1f), if (isActive) onClick() else snackbar.showMessage(context.getString(R.string.turn_on_tunnel))
onClick = { }) {
if (isActive) { Icon(Icons.Rounded.Info, "Info")
onClick()
} else {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_on_tunnel)),
)
}
},
) {
Icon(
Icons.Rounded.Info,
stringResource(R.string.info),
modifier = Modifier.size(24.dp),
)
} }
IconButton( IconButton(onClick = onCopy) {
modifier = Modifier.weight(1f), Icon(Icons.Rounded.CopyAll, "Copy")
onClick = onCopy,
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
} }
IconButton( IconButton(onClick = {
modifier = Modifier.weight(1f), if (isActive) {
onClick = { snackbar.showMessage(context.getString(R.string.turn_off_tunnel))
if (isActive) { } else {
viewModel.handleEvent( onHold()
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_off_tunnel)), onDelete()
) }
} else { }) {
onDelete() Icon(Icons.Rounded.Delete, "Delete")
}
},
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
} }
ScaledSwitch( ScaledSwitch(
modifier = Modifier modifier = Modifier.focusRequester(itemFocusRequester),
.focusRequester(itemFocusRequester)
.weight(1f),
checked = isActive, checked = isActive,
onClick = onSwitchClick, onClick = onSwitchClick,
) )
@@ -4,34 +4,44 @@ import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf 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.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.AddPeerButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel: ConfigViewModel = hiltViewModel()) { fun ConfigScreen(tunnelConf: TunnelConf?, viewModel: ConfigViewModel = hiltViewModel()) {
val context = LocalContext.current val context = LocalContext.current
val snackbar = SnackbarController.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -49,30 +59,13 @@ fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel:
} }
} }
LaunchedEffect(Unit) {
// set callback for navbar to invoke save
appViewModel.handleEvent(
AppEvent.SetScreenAction {
keyboardController?.hide()
viewModel.save(tunnelConf)
},
)
}
LaunchedEffect(tunnelConf) { LaunchedEffect(tunnelConf) {
viewModel.initFromTunnel(tunnelConf) viewModel.initFromTunnel(tunnelConf)
} }
LaunchedEffect(uiState.success) {
if (uiState.success == true) {
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)))
appViewModel.handleEvent(AppEvent.PopBackStack(true))
}
}
LaunchedEffect(uiState.message) { LaunchedEffect(uiState.message) {
uiState.message?.let { message -> uiState.message?.let { message ->
appViewModel.handleEvent(AppEvent.ShowMessage(message)) snackbar.showMessage(message.asString(context))
viewModel.setMessage(null) viewModel.setMessage(null)
} }
} }
@@ -85,24 +78,47 @@ fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel:
}, },
onError = { onError = {
viewModel.toggleShowAuthPrompt() viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authentication_failed))) snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
}, },
onFailure = { onFailure = {
viewModel.toggleShowAuthPrompt() viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authorization_failed))) snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
}, },
) )
} }
Column( Scaffold(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), topBar = {
modifier = Modifier TopNavBar(
.fillMaxSize() title = stringResource(R.string.edit_tunnel),
.verticalScroll(rememberScrollState()) trailing = {
.padding(horizontal = 24.dp), IconButton(onClick = {
) { keyboardController?.hide()
InterfaceSection(uiState, viewModel) viewModel.save(tunnelConf)
PeersSection(uiState, viewModel) }) {
AddPeerButton(viewModel) 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)
}
} }
} }
@@ -81,7 +81,7 @@ class ConfigViewModel @Inject constructor(
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
showAmneziaValues = show, showScripts = show,
configProxy = it.configProxy.copy( configProxy = it.configProxy.copy(
`interface` = `interface`, `interface` = `interface`,
), ),
@@ -136,14 +136,13 @@ class ConfigViewModel @Inject constructor(
val message = try { val message = try {
val saveConfig = buildTunnelConfFromState(tunnelConf) val saveConfig = buildTunnelConfFromState(tunnelConf)
tunnelRepository.save(saveConfig) tunnelRepository.save(saveConfig)
_uiState.update { it.copy(success = true) } StringValue.StringResource(R.string.config_changes_saved)
} catch (e: Exception) { } catch (e: Exception) {
setMessage( e.message?.let { message ->
e.message?.let { message -> (StringValue.DynamicString(message))
(StringValue.DynamicString(message)) } ?: StringValue.StringResource(R.string.unknown_error)
} ?: StringValue.StringResource(R.string.unknown_error),
)
} }
setMessage(message)
} }
private fun buildTunnelConfFromState(tunnelConf: TunnelConf?): TunnelConf { private fun buildTunnelConfFromState(tunnelConf: TunnelConf?): TunnelConf {
@@ -19,7 +19,7 @@ fun AddPeerButton(viewModel: ConfigViewModel) {
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 140.dp),
) { ) {
TextButton(onClick = { viewModel.addPeer() }) { TextButton(onClick = { viewModel.addPeer() }) {
Text(stringResource(R.string.add_peer)) Text(stringResource(R.string.add_peer))
@@ -45,7 +45,7 @@ fun InterfaceFields(
OutlinedTextField( OutlinedTextField(
value = interfaceState.privateKey, value = interfaceState.privateKey,
onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) }, onValueChange = { onInterfaceChange(interfaceState.copy(privateKey = it)) },
label = { Text(stringResource(R.string.private_key), style = MaterialTheme.typography.bodyMedium) }, label = { Text(stringResource(R.string.private_key)) },
modifier = Modifier.fillMaxWidth().clickable { modifier = Modifier.fillMaxWidth().clickable {
if (!isAuthenticated) showAuthPrompt() if (!isAuthenticated) showAuthPrompt()
}, },
@@ -75,7 +75,7 @@ fun InterfaceFields(
OutlinedTextField( OutlinedTextField(
value = interfaceState.publicKey, value = interfaceState.publicKey,
onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) }, onValueChange = { onInterfaceChange(interfaceState.copy(publicKey = it)) },
label = { Text(stringResource(R.string.public_key), style = MaterialTheme.typography.bodyMedium) }, label = { Text(stringResource(R.string.public_key)) },
enabled = false, enabled = false,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -105,7 +105,7 @@ fun InterfaceFields(
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(5.dp),
) { ) {
ConfigurationTextBox( ConfigurationTextBox(
value = interfaceState.dnsServers, value = interfaceState.dnsServers,
@@ -23,6 +23,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel 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.ConfigViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
@@ -33,8 +34,8 @@ fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(16.dp).focusGroup(), modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(),
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,8 +37,8 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
value = peer.preSharedKey, value = peer.preSharedKey,
enabled = isAuthenticated, enabled = isAuthenticated,
onValueChange = { onPeerChange(peer.copy(preSharedKey = it)) }, onValueChange = { onPeerChange(peer.copy(preSharedKey = it)) },
label = { Text(stringResource(R.string.preshared_key), style = MaterialTheme.typography.bodyMedium) }, label = { Text(stringResource(R.string.preshared_key)) },
placeholder = { Text(stringResource(R.string.optional), style = MaterialTheme.typography.bodyMedium) }, placeholder = { Text(stringResource(R.string.optional)) },
modifier = Modifier.fillMaxWidth().clickable { if (!isAuthenticated) showAuthPrompt() }, modifier = Modifier.fillMaxWidth().clickable { if (!isAuthenticated) showAuthPrompt() },
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
@@ -48,9 +47,9 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
OutlinedTextField( OutlinedTextField(
value = peer.persistentKeepalive, value = peer.persistentKeepalive,
onValueChange = { onPeerChange(peer.copy(persistentKeepalive = it)) }, onValueChange = { onPeerChange(peer.copy(persistentKeepalive = it)) },
label = { Text(stringResource(R.string.persistent_keepalive), style = MaterialTheme.typography.bodyMedium) }, label = { Text(stringResource(R.string.persistent_keepalive)) },
placeholder = { Text(stringResource(R.string.optional), style = MaterialTheme.typography.bodyMedium) }, placeholder = { Text(stringResource(R.string.optional_no_recommend)) },
trailingIcon = { Text(stringResource(R.string.seconds), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 10.dp)) }, trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
@@ -65,8 +64,8 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
OutlinedTextField( OutlinedTextField(
value = peer.allowedIps, value = peer.allowedIps,
onValueChange = { onPeerChange(peer.copy(allowedIps = it)) }, onValueChange = { onPeerChange(peer.copy(allowedIps = it)) },
label = { Text(stringResource(R.string.allowed_ips), style = MaterialTheme.typography.bodyMedium) }, label = { Text(stringResource(R.string.allowed_ips)) },
placeholder = { Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.bodyMedium) }, placeholder = { Text(stringResource(R.string.comma_separated_list)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
@@ -34,6 +34,7 @@ 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.ConfigViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) { fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
@@ -45,8 +46,8 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(16.dp).focusGroup(), modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(),
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -62,14 +63,14 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
modifier = Modifier.size(iconSize), modifier = Modifier.size(iconSize),
onClick = { viewModel.removePeer(index) }, onClick = { viewModel.removePeer(index) },
) { ) {
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete)) Icon(Icons.Rounded.Delete, contentDescription = "Delete")
} }
Column { Column {
IconButton( IconButton(
modifier = Modifier.size(iconSize), modifier = Modifier.size(iconSize),
onClick = { isDropDownExpanded = true }, onClick = { isDropDownExpanded = true },
) { ) {
Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.quick_actions)) Icon(Icons.Rounded.MoreVert, contentDescription = "More")
} }
DropdownMenu( DropdownMenu(
expanded = isDropDownExpanded, expanded = isDropDownExpanded,
@@ -13,5 +13,4 @@ data class ConfigUiState(
val isAuthenticated: Boolean = true, val isAuthenticated: Boolean = true,
val showAuthPrompt: Boolean = false, val showAuthPrompt: Boolean = false,
val message: StringValue? = null, val message: StringValue? = null,
val success: Boolean? = null,
) )
@@ -3,48 +3,58 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components.SplitTunnelContent import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components.SplitTunnelContent
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable @Composable
fun SplitTunnelScreen(appViewModel: AppViewModel, viewModel: SplitTunnelViewModel = hiltViewModel()) { fun SplitTunnelScreen(viewModel: SplitTunnelViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { Scaffold(
appViewModel.handleEvent(AppEvent.SetScreenAction { viewModel.saveChanges() }) topBar = {
} TopNavBar(
title = stringResource(R.string.tunneling_apps),
LaunchedEffect(uiState.success) { trailing = {
if (uiState.success == true) { IconButton(onClick = { viewModel.saveChanges() }) {
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved))) Icon(
appViewModel.handleEvent(AppEvent.PopBackStack(true)) imageVector = Icons.Outlined.Save,
} contentDescription = stringResource(R.string.save),
} )
}
Crossfade( },
targetState = uiState.loading,
animationSpec = tween(200),
modifier = Modifier
.fillMaxSize(),
) { isLoading ->
if (isLoading) {
SplitTunnelSkeleton()
} else {
SplitTunnelContent(
uiState = uiState,
onSplitOptionChange = viewModel::updateSplitOption,
onAppSelectionToggle = viewModel::toggleAppSelection,
onQueryChange = viewModel::onSearchQuery,
) )
},
) { 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,
)
}
} }
} }
} }
@@ -22,22 +22,24 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.animation.ShimmerEffect import com.zaneschepke.wireguardautotunnel.ui.common.animation.ShimmerEffect
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun SplitTunnelSkeleton() { fun SplitTunnelSkeleton() {
val shimmerBrush = ShimmerEffect() val shimmerBrush = ShimmerEffect()
Column( Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp), .padding(top = 24.dp.scaledHeight()),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp.scaledWidth())
.height(45.dp), .height(45.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -55,7 +57,7 @@ fun SplitTunnelSkeleton() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp.scaledWidth())
.height(45.dp), .height(45.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -78,7 +80,7 @@ fun SplitTunnelSkeleton() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp.scaledWidth(), vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
@@ -4,14 +4,17 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.ui.Route 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.SplitOption
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState 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.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -151,7 +154,7 @@ class SplitTunnelViewModel @Inject constructor(
} }
} }
saveProxyConfig(configProxy, tunnel) saveProxyConfig(configProxy, tunnel)
_uiState.update { it.copy(success = true) } SnackbarController.showMessage(StringValue.StringResource(R.string.config_changes_saved))
} }
private suspend fun saveProxyConfig(proxy: ConfigProxy, tunnel: TunnelConf) { private suspend fun saveProxyConfig(proxy: ConfigProxy, tunnel: TunnelConf) {
@@ -18,6 +18,7 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) { fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) {
@@ -36,7 +37,7 @@ fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) {
painter = rememberDrawablePainter(icon), painter = rememberDrawablePainter(icon),
contentDescription = appInfo.name, contentDescription = appInfo.name,
modifier = Modifier modifier = Modifier
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp.scaledWidth())
.size(iconSize), .size(iconSize),
) )
}, },
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable @Composable
fun AppListSection(apps: List<Pair<TunnelApp, Boolean>>, onAppSelectionToggle: (String) -> Unit, onQueryChange: (String) -> Unit, query: String) { fun AppListSection(apps: List<Pair<TunnelApp, Boolean>>, onAppSelectionToggle: (String) -> Unit, onQueryChange: (String) -> Unit, query: String) {
@@ -47,11 +48,11 @@ fun AppListSection(apps: List<Pair<TunnelApp, Boolean>>, onAppSelectionToggle: (
leading = { leading = {
Icon(Icons.Outlined.Search, stringResource(R.string.search)) Icon(Icons.Outlined.Search, stringResource(R.string.search))
}, },
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.background,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(inputHeight) .height(inputHeight)
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp.scaledWidth()),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import java.util.* import java.util.*
@Composable @Composable
@@ -30,9 +31,9 @@ fun SplitOptionSelector(selectedOption: SplitOption, onOptionChange: (SplitOptio
MultiChoiceSegmentedButtonRow( MultiChoiceSegmentedButtonRow(
modifier = Modifier modifier = Modifier
.background(color = MaterialTheme.colorScheme.surface) .background(color = MaterialTheme.colorScheme.background)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp.scaledWidth())
.height(inputHeight), .height(inputHeight),
) { ) {
SplitOption.entries.forEachIndexed { index, entry -> SplitOption.entries.forEachIndexed { index, entry ->
@@ -61,7 +62,7 @@ fun SplitOptionSelector(selectedOption: SplitOption, onOptionChange: (SplitOptio
}, },
colors = SegmentedButtonDefaults.colors().copy( colors = SegmentedButtonDefaults.colors().copy(
activeContainerColor = MaterialTheme.colorScheme.surface, activeContainerColor = MaterialTheme.colorScheme.surface,
inactiveContainerColor = MaterialTheme.colorScheme.surface, inactiveContainerColor = MaterialTheme.colorScheme.background,
), ),
onCheckedChange = { onOptionChange(entry) }, onCheckedChange = { onOptionChange(entry) },
checked = active, checked = active,
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.SplitOption
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable @Composable
fun SplitTunnelContent( fun SplitTunnelContent(
@@ -19,11 +20,11 @@ fun SplitTunnelContent(
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 24.dp), .padding(top = 24.dp.scaledHeight()),
) { ) {
SplitOptionSelector( SplitOptionSelector(
selectedOption = uiState.splitOption, selectedOption = uiState.splitOption,
@@ -8,5 +8,4 @@ data class SplitTunnelUiState(
val tunneledApps: SplitTunnelApps = emptyList(), val tunneledApps: SplitTunnelApps = emptyList(),
val splitOption: SplitOption = SplitOption.ALL, val splitOption: SplitOption = SplitOption.ALL,
val searchQuery: String = "", val searchQuery: String = "",
val success: Boolean? = null,
) )
@@ -1,64 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.AutoTunnelingItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PingConfigItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PingRestartItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.PrimaryTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.ServerIpv4Item
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components.SplitTunnelingItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun TunnelOptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = listOf(
PrimaryTunnelItem(tunnelConf, viewModel),
AutoTunnelingItem(tunnelConf),
ServerIpv4Item(tunnelConf, viewModel),
SplitTunnelingItem(tunnelConf),
),
)
SurfaceSelectionGroupButton(
items = buildList {
add(PingRestartItem(tunnelConf, viewModel))
if (tunnelConf.isPingEnabled) {
add(PingConfigItem(tunnelConf, viewModel))
}
},
)
}
}
@@ -1,38 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
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.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Bolt,
title = {
Text(
text = stringResource(R.string.auto_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.tunnel_specific_settings),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) }
},
onClick = { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) },
)
}
@@ -1,60 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
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 com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PingConfigItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
title = {},
description = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SubmitConfigurationTextBox(
value = tunnelConf.pingIp,
label = stringResource(R.string.set_custom_ping_ip),
hint = stringResource(R.string.default_ping_ip),
isErrorValue = { it?.isNotBlank() == true && !it.isValidIpv4orIpv6Address() },
onSubmit = { ip -> viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip)) },
)
SubmitConfigurationTextBox(
value = tunnelConf.pingInterval?.let { (it / 1000).toString() },
label = stringResource(R.string.set_custom_ping_internal),
hint = "(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = { it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false },
onSubmit = { interval -> viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval)) },
)
SubmitConfigurationTextBox(
value = tunnelConf.pingCooldown?.let { (it / 1000).toString() },
label = stringResource(R.string.set_custom_ping_cooldown),
hint = "(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = { it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false },
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
)
}
},
)
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.NetworkPing,
title = {
Text(
text = stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PrimaryTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Star,
title = {
Text(
text = stringResource(R.string.primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPrimaryTunnel,
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ServerIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Dns,
title = {
Text(
text = stringResource(R.string.server_ipv4),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.prefer_ipv4),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isIpv4Preferred,
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
}
@@ -1,32 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
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.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.AutoMirrored.Outlined.CallSplit,
title = {
Text(
text = stringResource(R.string.splt_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) }
},
onClick = { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) },
)
}
@@ -0,0 +1,90 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.util.extensions.launchNotificationSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun AppearanceScreen() {
val navController = LocalNavController.current
val context = LocalContext.current
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.appearance))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Translate,
title = { Text(stringResource(R.string.language), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Language) },
trailing = {
ForwardButton { navController.navigate(Route.Language) }
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
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() }
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Contrast,
title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Display) },
trailing = {
ForwardButton { navController.navigate(Route.Display) }
},
),
),
)
}
}
}
@@ -0,0 +1,64 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
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.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 com.zaneschepke.wireguardautotunnel.R
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.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) {
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.display_theme))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
IconSurfaceButton(
title = stringResource(R.string.automatic),
onClick = {
viewModel.handleEvent(AppEvent.SetTheme(Theme.AUTOMATIC))
},
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
)
IconSurfaceButton(
title = stringResource(R.string.light),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.LIGHT)) },
selected = appUiState.generalState.theme == Theme.LIGHT,
)
IconSurfaceButton(
title = stringResource(R.string.dark),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DARK)) },
selected = appUiState.generalState.theme == Theme.DARK,
)
IconSurfaceButton(
title = stringResource(R.string.dynamic),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DYNAMIC)) },
selected = appUiState.generalState.theme == Theme.DYNAMIC,
)
}
}
}
@@ -0,0 +1,129 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
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.material.icons.Icons
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Lan
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.dp
import com.zaneschepke.wireguardautotunnel.R
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.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, viewModel: AppViewModel) {
val context = LocalContext.current
val toggleVpnSwitch = withVpnPermission<Boolean> { viewModel.handleEvent(AppEvent.ToggleVpnKillSwitch) }
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.kill_switch))
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
if (!context.isRunningOnTv()) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.AdminPanelSettings,
title = {
Text(
stringResource(R.string.native_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { context.launchVpnSettings() },
trailing = {
ForwardButton { context.launchVpnSettings() }
},
),
),
)
}
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.VpnKey,
title = {
Text(
stringResource(R.string.vpn_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
toggleVpnSwitch.invoke(true)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isVpnKillSwitchEnabled,
onClick = {
toggleVpnSwitch.invoke(true)
},
)
},
),
)
if (uiState.appSettings.isVpnKillSwitchEnabled) {
add(
SelectionItem(
Icons.Outlined.Lan,
title = {
Text(
stringResource(R.string.allow_lan_traffic),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) },
description = {
Text(
stringResource(R.string.bypass_lan_for_kill_switch),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isLanOnKillSwitchEnabled,
onClick = {
viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch)
},
)
},
),
)
}
},
)
}
}
}
@@ -0,0 +1,96 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.Scaffold
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.unit.dp
import com.zaneschepke.wireguardautotunnel.R
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.*
@Composable
fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val collator = Collator.getInstance(Locale.getDefault())
val locales = LocaleUtil.supportedLocales.map {
val tag = it.replace("_", "-")
Locale.forLanguageTag(tag)
}
val sortedLocales =
remember(locales) {
locales.sortedWith(compareBy(collator) { it.getDisplayName(it) }).toList()
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.language))
},
) { padding ->
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize().padding(padding)
.padding(horizontal = 24.dp.scaledWidth()),
) {
item {
Box(modifier = Modifier.padding(top = 24.dp.scaledHeight())) {
SelectionItemButton(
buttonText = stringResource(R.string.automatic),
onClick = {
viewModel.handleEvent(AppEvent.SetLocale(LocaleUtil.OPTION_PHONE_LANGUAGE))
},
trailing = {
with(appUiState.generalState.locale) {
if (this == LocaleUtil.OPTION_PHONE_LANGUAGE || this == null) {
SelectedLabel()
}
}
},
ripple = false,
)
}
}
items(sortedLocales, key = { it }) { locale ->
SelectionItemButton(
buttonText = locale.getDisplayLanguage(locale).replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } +
if (locale.toLanguageTag().contains("-")) {
" (${locale.getDisplayCountry(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }})"
} else {
""
},
onClick = {
viewModel.handleEvent(AppEvent.SetLocale(locale.toLanguageTag()))
},
trailing = {
if (locale.toLanguageTag() == appUiState.generalState.locale) {
SelectedLabel()
}
},
ripple = false,
)
}
}
}
}
@@ -0,0 +1,106 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
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.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.font.FontWeight
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.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(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
LaunchedEffect(Unit, appUiState) {
if (appUiState.generalState.isLocationDisclosureShown) navController.goFromRoot(Route.AutoTunnel)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier.fillMaxSize().systemBarsPadding().padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
val icon = Icons.Rounded.PermScanWifi
Icon(
icon,
contentDescription = icon.name,
modifier = Modifier
.padding(30.dp.scaledHeight())
.size(128.dp.scaledHeight()),
)
Text(
stringResource(R.string.prominent_background_location_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
Text(
stringResource(R.string.prominent_background_location_message),
style = MaterialTheme.typography.bodyLarge,
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.LocationOn,
title = {
Text(
stringResource(R.string.launch_app_settings),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
},
trailing = {
ForwardButton {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
}
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
title = { Text(stringResource(R.string.skip), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
trailing = {
ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
},
),
),
)
}
}
@@ -1,88 +1,375 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewQuilt
import androidx.compose.material.icons.filled.AppShortcut
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.Pin
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.VpnLock
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
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.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AlwaysOnVpnItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppearanceItem import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ExportTunnelsBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.PinLockItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
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.AppViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) { fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val snackbar = SnackbarController.current
val isRunningOnTv = remember { context.isRunningOnTv() } val isRunningOnTv = remember { context.isRunningOnTv() }
val interactionSource = remember { MutableInteractionSource() }
if (appViewState.showBottomSheet) { val interactionSource = remember { MutableInteractionSource() }
ExportTunnelsBottomSheet(viewModel, isRunningOnTv) var showAuthPrompt by remember { mutableStateOf(false) }
var showExportSheet by remember { mutableStateOf(false) }
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
showExportSheet = 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),
)
},
)
}
if (showExportSheet) {
ModalBottomSheet(onDismissRequest = { showExportSheet = false }) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.AMNEZIA))
}
.padding(10.dp),
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(id = R.string.export_amnezia),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.export_amnezia),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.WG))
}
.padding(10.dp),
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(id = R.string.export_wireguard),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.export_wireguard),
modifier = Modifier.padding(10.dp),
)
}
}
} }
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top), verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier modifier =
Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxSize() .fillMaxSize().systemBarsPadding().imePadding()
.padding(top = 24.dp) .padding(top = 24.dp.scaledHeight())
.padding(bottom = 40.dp) .padding(bottom = 40.dp.scaledHeight())
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp.scaledWidth())
.then( .then(
if (!isRunningOnTv) { if (!isRunningOnTv) {
Modifier.clickable( Modifier.clickable(
indication = null, indication = null,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { focusManager.clearFocus() }, ) {
) focusManager.clearFocus()
}
} else { } else {
Modifier Modifier
}, },
), ),
) { ) {
val onAutoTunnelClick = {
if (!uiState.generalState.isLocationDisclosureShown) {
navController.navigate(Route.LocationDisclosure)
} else {
navController.navigate(Route.AutoTunnel)
}
}
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = buildList { listOf(
add(AppShortcutsItem(uiState, viewModel)) SelectionItem(
if (!isRunningOnTv) add(AlwaysOnVpnItem(uiState, viewModel)) Icons.Outlined.Bolt,
add(KillSwitchItem()) title = { Text(stringResource(R.string.auto_tunneling), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
add(RestartAtBootItem(uiState, viewModel)) description = {
}, Text(
stringResource(R.string.on_demand_rules),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
onAutoTunnelClick()
},
trailing = {
ForwardButton(Modifier.focusable()) { onAutoTunnelClick() }
},
),
),
) )
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = buildList { buildList {
add(AppearanceItem()) add(
add(LocalLoggingItem(uiState, viewModel)) SelectionItem(
if (uiState.generalState.isLocalLogsEnabled) add(ReadLogsItem()) Icons.Filled.AppShortcut,
add(PinLockItem(uiState, viewModel)) {
ScaledSwitch(
uiState.appSettings.isShortcutsEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
)
},
title = {
Text(
stringResource(R.string.enabled_app_shortcuts),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
),
)
if (!isRunningOnTv) {
add(
SelectionItem(
Icons.Outlined.VpnLock,
{
ScaledSwitch(
enabled = !(
(
uiState.appSettings.isTunnelOnWifiEnabled ||
uiState.appSettings.isTunnelOnEthernetEnabled ||
uiState.appSettings.isTunnelOnMobileDataEnabled
) &&
uiState.appSettings.isAutoTunnelEnabled
),
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
checked = uiState.appSettings.isAlwaysOnVpnEnabled,
)
},
title = {
Text(
stringResource(R.string.always_on_vpn_support),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
),
)
}
add(
SelectionItem(
Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_options),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.KillSwitch)
},
trailing = {
ForwardButton { navController.navigate(Route.KillSwitch) }
},
),
)
add(
SelectionItem(
Icons.Outlined.Restore,
{
ScaledSwitch(
uiState.appSettings.isRestoreOnBootEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
)
},
title = {
Text(
stringResource(R.string.restart_at_boot),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
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(
Icons.AutoMirrored.Outlined.ViewQuilt,
title = { Text(stringResource(R.string.appearance), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = {
navController.navigate(Route.Appearance)
},
trailing = {
ForwardButton { navController.navigate(Route.Appearance) }
},
),
SelectionItem(
Icons.Outlined.Pin,
title = {
Text(
stringResource(R.string.enable_app_lock),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
uiState.generalState.isPinLockEnabled,
onClick = {
onPinLockToggle()
},
)
},
onClick = {
onPinLockToggle()
},
),
),
)
if (!isRunningOnTv) { if (!isRunningOnTv) {
SurfaceSelectionGroupButton( SurfaceSelectionGroupButton(
items = listOf(KernelModeItem(uiState, viewModel)), listOf(
SelectionItem(
Icons.Outlined.Code,
title = { Text(stringResource(R.string.kernel), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
description = {
Text(
stringResource(R.string.use_kernel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isKernelEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleKernelMode) },
enabled = !(
uiState.appSettings.isAutoTunnelEnabled ||
uiState.appSettings.isAlwaysOnVpnEnabled ||
uiState.activeTunnels.isNotEmpty()
),
)
},
onClick = {
viewModel.handleEvent(AppEvent.ToggleKernelMode)
},
),
),
)
}
if (!isRunningOnTv) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.FolderZip,
title = {
Text(
stringResource(R.string.export_configs),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
if (uiState.tunnels.isEmpty()) return@SelectionItem context.showToast(R.string.tunnel_required)
showAuthPrompt = true
},
),
),
) )
} }
} }
@@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.DisplayThemeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.LanguageItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.NotificationsItem
@Composable
fun AppearanceScreen() {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = listOf(LanguageItem()),
)
SurfaceSelectionGroupButton(
items = listOf(NotificationsItem()),
)
SurfaceSelectionGroupButton(
items = listOf(DisplayThemeItem()),
)
}
}
@@ -1,29 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
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.navigation.LocalNavController
@Composable
fun DisplayThemeItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Contrast,
title = {
Text(
text = stringResource(R.string.display_theme),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { navController.navigate(Route.Display) } },
onClick = { navController.navigate(Route.Display) },
)
}
@@ -1,29 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
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.navigation.LocalNavController
@Composable
fun LanguageItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Translate,
title = {
Text(
text = stringResource(R.string.language),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { navController.navigate(Route.Language) } },
onClick = { navController.navigate(Route.Language) },
)
}

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