Compare commits

..

1 Commits

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 13:27:34 +00:00
190 changed files with 4688 additions and 5293 deletions
+6 -3
View File
@@ -86,6 +86,7 @@ android {
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
@@ -94,6 +95,7 @@ android {
create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease"
versionNameSuffix = "-pre"
resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
}
@@ -101,6 +103,7 @@ android {
create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly"
versionNameSuffix = "-nightly"
resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
}
@@ -157,7 +160,6 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.storage)
// test
testImplementation(libs.junit)
@@ -202,12 +204,13 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
// icons
implementation(libs.material.icons.extended)
// serialization
implementation(libs.kotlinx.serialization.json)
// ui
// barcode scanning
implementation(libs.zxing.android.embedded)
implementation(libs.material.icons.extended)
// bio
implementation(libs.androidx.biometric.ktx)
+1 -1
View File
@@ -63,7 +63,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustNothing"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.WireguardAutoTunnel"
android:configChanges="orientation|screenSize|keyboardHidden"
>
@@ -1,35 +1,25 @@
package com.zaneschepke.wireguardautotunnel
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
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.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.imePadding
import androidx.compose.foundation.layout.padding
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.QuestionMark
import androidx.compose.material.icons.rounded.Settings
@@ -37,16 +27,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -54,44 +39,39 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
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.CustomBottomNavbar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.DynamicTopAppBar
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.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
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.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen
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.appearance.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.display.DisplayScreen
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.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint
import org.amnezia.awg.backend.GoBackend.VpnService
import timber.log.Timber
import javax.inject.Inject
import kotlin.system.exitProcess
@@ -105,12 +85,14 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var tunnelManager: TunnelManager
@Inject
lateinit var shortcutManager: ShortcutManager
@Inject
lateinit var networkMonitor: NetworkMonitor
private var lastLocationPermissionState: Boolean? = null
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -125,244 +107,152 @@ class MainActivity : AppCompatActivity() {
installSplashScreen().apply {
setKeepOnScreenCondition {
!viewModel.appViewState.value.isAppReady
!viewModel.isAppReady.value
}
}
setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
val configurationChange by viewModel.configurationChange.collectAsStateWithLifecycle()
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
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 =
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))
LaunchedEffect(configurationChange) {
if (configurationChange) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
}
val batteryActivity = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _: ActivityResult ->
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
with(appViewState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
}
LaunchedEffect(errorMessage) {
errorMessage?.let {
snackbar.showSnackbar(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) {
if (popBackStack) {
navController.popBackStack()
viewModel.handleEvent(AppEvent.PopBackStack(false))
}
}
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}")
},
)
}
with(appUiState.appSettings) {
LaunchedEffect(isShortcutsEnabled) {
if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts()
shortcutManager.addShortcuts()
}
}
CompositionLocalProvider(LocalNavController provides navController) {
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
Scaffold(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
}
},
snackbarHost = {
SnackbarHost(snackbar) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
topBar = {
DynamicTopAppBar(navBarState)
},
bottomBar = {
AnimatedVisibility(
visible = navBarState.showBottom,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
CustomBottomNavbar(
SnackbarControllerProvider { host ->
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
Scaffold(
contentWindowInsets = WindowInsets(0),
snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
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(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
onClick = { navController.goFromRoot(Route.Settings) },
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
onClick = { navController.goFromRoot(Route.Support) },
),
),
navBarState = navBarState,
)
}
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)
.padding(padding)
.consumeWindowInsets(padding)
.imePadding(),
) {
NavHost(
navController,
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(appUiState, appViewState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(appUiState, appViewState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(appUiState, viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(appUiState, viewModel)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState, viewModel)
}
composable<Route.Support> {
SupportScreen()
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState)
}
composable<Route.Logs> {
LogsScreen(appViewState, viewModel)
}
composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>()
val config = appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config, viewModel)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
TunnelOptionsScreen(config, appUiState, viewModel)
},
) { padding ->
Box(modifier = Modifier.Companion.fillMaxSize().padding(padding)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(
uiState = appUiState,
)
}
composable<Route.Settings> {
SettingsScreen(
appViewModel = viewModel,
uiState = appUiState,
)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(viewModel, appUiState)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(
appUiState.appSettings,
)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState)
}
composable<Route.Support> {
SupportScreen(appUiState, viewModel)
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState.appSettings, viewModel)
}
composable<Route.Logs> {
LogsScreen()
}
composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>()
val config =
appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
OptionsScreen(config, appUiState)
}
}
composable<Route.Lock> {
PinLockScreen(viewModel)
}
composable<Route.Scanner> {
ScannerScreen()
}
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)
}
}
}
composable<Route.Lock> {
PinLockScreen(viewModel)
}
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.previousBackStackEntry == null || !navController.popBackStack()) {
this@MainActivity.finish()
}
}
}
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.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -86,7 +87,7 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
tunnelManager.startTunnel(it)
}
} 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)
applicationScope.launch {
withContext(mainDispatcher) {
if (appDataRepository.appState.isLocalLogsEnabled() && !isRunningOnTv()) logReader.start()
}
if (!appDataRepository.settings.get().isKernelEnabled) {
tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
}
@@ -102,9 +106,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
LocaleUtil.changeLocale(it)
}
}
appDataRepository.appState.isLocalLogsEnabled().let { enabled ->
if (enabled) logReader.start()
}
}
}
@@ -134,19 +135,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
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
private set
}
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -4,9 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
@@ -36,15 +36,11 @@ class NotificationActionReceiver : BroadcastReceiver() {
NotificationAction.AUTO_TUNNEL_OFF.name -> serviceManager.stopAutoTunnel()
NotificationAction.TUNNEL_OFF.name -> {
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)
tunnelManager.stopTunnel(tunnel)
}
}
}
}
companion object {
const val STOP_ALL_TUNNELS_ID = 0
}
}
@@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -44,14 +44,14 @@ class RestartReceiver : BroadcastReceiver() {
}
Timber.d("RestartReceiver triggered with action: ${intent.action}")
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
applicationScope.launch(ioDispatcher) {
serviceManager.updateTunnelTile()
serviceManager.updateAutoTunnelTile()
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) {
Timber.d("Starting auto-tunnel on boot/update")
serviceManager.startAutoTunnel()
serviceManager.startAutoTunnel(true)
} else {
Timber.d("Restoring previous tunnel state")
tunnelManager.restorePreviousState()
@@ -4,8 +4,8 @@ import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels
import com.zaneschepke.wireguardautotunnel.util.StringValue
interface NotificationManager {
@@ -39,6 +39,7 @@ interface NotificationManager {
fun show(notificationId: Int, notification: Notification)
companion object {
const val KERNEL_SERVICE_NOTIFICATION_ID = 123
const val AUTO_TUNNEL_NOTIFICATION_ID = 122
const val VPN_NOTIFICATION_ID = 100
const val EXTRA_ID = "id"
@@ -12,9 +12,9 @@ import android.graphics.Color
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.broadcast.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager.Companion.EXTRA_ID
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.util.StringValue
@@ -3,12 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.VpnService
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
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.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
@@ -20,21 +18,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
class ServiceManager @Inject constructor(
private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
private val appDataRepository: AppDataRepository,
) {
private val autoTunnelMutex = Mutex()
private val _autoTunnelActive = MutableStateFlow(false)
val autoTunnelActive = _autoTunnelActive.asStateFlow()
@@ -52,71 +45,69 @@ class ServiceManager @Inject constructor(
}.onFailure { Timber.e(it) }
}
fun hasVpnPermission(): Boolean {
return VpnService.prepare(context) == null
}
suspend fun startAutoTunnel() {
autoTunnelMutex.withLock {
fun startAutoTunnel(background: Boolean) {
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
if (autoTunnelService.isCompleted) {
_autoTunnelActive.update { true }
return
return@launch
}
runCatching {
autoTunnelService = CompletableDeferred()
startService(AutoTunnelService::class.java, !WireGuardAutoTunnel.isForeground())
startService(AutoTunnelService::class.java, background)
_autoTunnelActive.update { true }
}.onFailure {
Timber.e(it)
_autoTunnelActive.update { false }
}
withContext(mainDispatcher) { updateAutoTunnelTile() }
}
updateAutoTunnelTile()
}
suspend fun stopAutoTunnel() {
autoTunnelMutex.withLock {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = false))
if (!autoTunnelService.isCompleted) return
fun startTunnelForegroundService(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (backgroundService.isCompleted) return@launch
runCatching {
val service = autoTunnelService.await()
service.stop()
_autoTunnelActive.update { false }
autoTunnelService = CompletableDeferred()
backgroundService = CompletableDeferred()
startService(TunnelForegroundService::class.java, true)
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { backgroundService.await() }
?: throw IllegalStateException("Background service start timed out")
service.start(tunnelConf)
}.onFailure {
Timber.e(it)
}
withContext(mainDispatcher) { updateAutoTunnelTile() }
}
}
fun startTunnelForegroundService() {
if (backgroundService.isCompleted) return
runCatching {
backgroundService = CompletableDeferred()
startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground())
}.onFailure {
Timber.e(it)
}
}
suspend fun stopTunnelForegroundService() {
if (!backgroundService.isCompleted) return
runCatching {
val service = backgroundService.await()
service.stop()
backgroundService = CompletableDeferred()
}.onFailure {
Timber.e(it)
}
}
fun toggleAutoTunnel() {
fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel()
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.start(tunnelConf)
}.onFailure {
Timber.e(it)
}
}
}
fun stopTunnelForegroundService() {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.stop()
backgroundService = CompletableDeferred()
}.onFailure {
Timber.e(it)
}
}
}
fun toggleAutoTunnel(background: Boolean) {
applicationScope.launch(ioDispatcher) {
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel(background)
}
}
@@ -127,4 +118,25 @@ class ServiceManager @Inject constructor(
fun updateTunnelTile() {
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.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -34,9 +28,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@AndroidEntryPoint
@@ -58,22 +50,11 @@ class TunnelForegroundService : LifecycleService() {
@Inject
lateinit var tunnelRepo: TunnelRepository
@Inject
lateinit var tunnelManager: TunnelManager
private val isNetworkConnected = MutableStateFlow(true)
private val tunnelJobs = ConcurrentHashMap<TunnelConf, Job>()
override fun onCreate() {
super.onCreate()
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? {
@@ -82,78 +63,31 @@ class TunnelForegroundService : LifecycleService() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
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(
this@TunnelForegroundService,
NotificationManager.VPN_NOTIFICATION_ID,
onCreateNotification(),
NotificationManager.KERNEL_SERVICE_NOTIFICATION_ID,
createNotification(tunnelConf),
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
launch { startNetworkMonitorJob() }
startNetworkMonitorJob()
// job to trigger stats emit on interval
launch { startTunnelStatsJob(tunnelConf) }
startTunnelStatsJob(tunnelConf)
// monitor changes to the tunnel config
launch { startTunnelConfChangesJob(tunnelConf) }
// monitor tunnel ping
launch { startPingJob(tunnelConf) }
startTunnelConfChangesJob(tunnelConf)
startPingJob(tunnelConf)
}
private fun findMissingKeys(map1: Map<TunnelConf, Any>, map2: Map<TunnelConf, Any>): Pair<Set<TunnelConf>, Set<TunnelConf>> {
val missingMap1 = map2.keys - map1.keys
val missingMap2 = map1.keys - map2.keys
return missingMap1 to missingMap2
}
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
private fun startTunnelConfChangesJob(tunnelConf: TunnelConf) = lifecycleScope.launch(ioDispatcher) {
tunnelRepo.flow
.flowOn(ioDispatcher)
.map { storedTunnels ->
@@ -162,20 +96,17 @@ class TunnelForegroundService : LifecycleService() {
.filterNotNull()
// only emit when one of these 3 values change
.distinctUntilChanged { old, new ->
old == new
old.tunName == new.tunName && old.wgQuick == new.wgQuick && old.amQuick == new.amQuick
}
.collect { storedTunnel ->
if (tunnelConf != storedTunnel) {
if (tunnelConf.isTunnelConfigChanged(storedTunnel)) {
Timber.d("Config changed for ${storedTunnel.tunName}, bouncing")
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(storedTunnel, TunnelStatus.StopReason.CONFIG_CHANGED)
}
tunnelConf.bounceTunnel(storedTunnel)
}
}
}
private suspend fun startNetworkMonitorJob() {
private fun startNetworkMonitorJob() = lifecycleScope.launch(ioDispatcher) {
networkMonitor.networkStatusFlow
.flowOn(ioDispatcher)
.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) {
tunnelManager.updateTunnelStatistics(tunnel)
tunnel.onUpdateStatistics()
delay(STATS_DELAY)
}
}
// TODO fix cooldown
private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope {
private fun startPingJob(tunnel: TunnelConf) = lifecycleScope.launch(ioDispatcher) {
delay(PING_START_DELAY)
while (isActive) {
val shouldBounce = shouldBounceTunnel(tunnel)
val delayMs = if (shouldBounce) {
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(tunnel, TunnelStatus.StopReason.PING)
}
tunnel.bounceTunnel(tunnel)
tunnel.pingCooldown ?: Constants.PING_COOLDOWN
} else {
tunnel.pingInterval ?: Constants.PING_INTERVAL
@@ -223,7 +150,6 @@ class TunnelForegroundService : LifecycleService() {
}
fun stop() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -233,7 +159,7 @@ class TunnelForegroundService : LifecycleService() {
super.onDestroy()
}
private fun createTunnelNotification(tunnelConf: TunnelConf): Notification {
private fun createNotification(tunnelConf: TunnelConf): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = "${getString(R.string.tunnel_running)} - ${tunnelConf.tunName}",
@@ -243,30 +169,8 @@ 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 {
const val STATS_DELAY = 1_000L
const val PING_START_DELAY = 30_000L
// ipv6 disabled or block on network
// const val userspaceStartFailed = "Failed to send handshake initiation: write udp [::]"
// const val ipv6Fails = "Failed to send data packets: write udp [::]"
// const val ipv4Fails = "Failed to send data packets: write udp 0.0.0.0:51820"
}
}
@@ -14,8 +14,8 @@ import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotificati
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
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.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
@@ -30,7 +30,6 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -64,6 +63,10 @@ class AutoTunnelService : LifecycleService() {
@Inject
lateinit var serviceManager: ServiceManager
@Inject
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
@Inject
lateinit var tunnelManager: TunnelManager
@@ -73,12 +76,16 @@ class AutoTunnelService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
private var killSwitchJob: Job? = null
override fun onCreate() {
super.onCreate()
serviceManager.autoTunnelService.complete(this)
launchWatcherNotification()
lifecycleScope.launch(mainImmediateDispatcher) {
runCatching {
launchWatcherNotification()
}.onFailure {
Timber.e(it)
}
}
}
override fun onBind(intent: Intent): IBinder? {
@@ -96,11 +103,13 @@ class AutoTunnelService : LifecycleService() {
fun start() {
kotlin.runCatching {
launchWatcherNotification()
initWakeLock()
lifecycleScope.launch(mainImmediateDispatcher) {
launchWatcherNotification()
initWakeLock()
}
startAutoTunnelJob()
startAutoTunnelStateJob()
killSwitchJob = startKillSwitchJob()
startKillSwitchJob()
}.onFailure {
Timber.e(it)
}
@@ -113,20 +122,9 @@ class AutoTunnelService : LifecycleService() {
override fun onDestroy() {
serviceManager.autoTunnelService = CompletableDeferred()
restoreVpnKillSwitch()
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)) {
val notification =
notificationManager.createNotification(
@@ -8,8 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -37,18 +37,19 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
override fun onStartListening() {
super.onStartListening()
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
Timber.d("Start listening called for auto tunnel tile")
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleScope.launch {
serviceManager.autoTunnelActive.collect {
if (it) return@collect setActive()
setInactive()
if (it) setActive() else setInactive()
}
}
lifecycleScope.launch {
appDataRepository.tunnels.flow.collect {
if (it.isEmpty()) {
setUnavailable()
} else {
if (qsTile.state == Tile.STATE_ACTIVE) setInactive()
}
}
}
@@ -62,7 +63,7 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
serviceManager.stopAutoTunnel()
setInactive()
} else {
serviceManager.startAutoTunnel()
serviceManager.startAutoTunnel(true)
setActive()
}
}
@@ -10,12 +10,9 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
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.state.TunnelState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -59,55 +56,18 @@ class TunnelControlTile : TileService(), LifecycleOwner {
}
}
private suspend fun updateTileState() {
try {
val tunnels = appDataRepository.tunnels.getAll()
if (tunnels.isEmpty()) {
setUnavailable()
return
private fun updateTileState() = lifecycleScope.launch {
val tunnels = appDataRepository.tunnels.getAll()
if (tunnels.isEmpty()) return@launch setUnavailable()
with(tunnelManager.activeTunnels.value) {
if (isNotEmpty()) if (size == 1) {
tunnels.firstOrNull { it.id == keys.first().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()
}
}
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()
}
appDataRepository.getStartTunnelConfig()?.let {
updateTile(it.tunName, false)
}
}
@@ -116,17 +76,8 @@ class TunnelControlTile : TileService(), LifecycleOwner {
unlockAndRun {
lifecycleScope.launch {
if (tunnelManager.activeTunnels.value.isNotEmpty()) return@launch tunnelManager.stopTunnel()
val lastActive = WireGuardAutoTunnel.getLastActiveTunnels()
if (lastActive.isEmpty()) {
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
}
} else {
lastActive.forEach { id ->
appDataRepository.tunnels.getById(id)?.let {
tunnelManager.startTunnel(it)
}
}
appDataRepository.getStartTunnelConfig()?.let {
tunnelManager.startTunnel(it)
}
}
}
@@ -2,12 +2,12 @@ package com.zaneschepke.wireguardautotunnel.core.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -53,7 +53,7 @@ class ShortcutsActivity : ComponentActivity() {
}
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
when (intent.action) {
Action.START.name -> serviceManager.startAutoTunnel()
Action.START.name -> serviceManager.startAutoTunnel(true)
Action.STOP.name -> serviceManager.stopAutoTunnel()
}
}
@@ -1,6 +1,11 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.BackendException
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.di.ApplicationScope
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.state.TunnelState
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.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -18,66 +26,88 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
abstract class BaseTunnel(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
private val notificationManager: NotificationManager,
) : TunnelProvider {
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
private val tunThreads = ConcurrentHashMap<Int, Thread>()
override val activeTunnels = activeTuns.asStateFlow()
private val tunMutex = Mutex()
private val tunStatusMutex = Mutex()
private val isBounce = AtomicBoolean(false)
private val isBouncing = AtomicBoolean(false)
private val mutex = Mutex()
abstract suspend fun startBackend(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 {
return serviceManager.hasVpnPermission()
private fun isTunnelActive(id: Int) = (activeTuns.value.any { it.key.id == id })
private fun handleBackendThrowable(throwable: Throwable) {
val backendError = when (throwable) {
is BackendException -> throwable.toBackendError()
is org.amnezia.awg.backend.BackendException -> throwable.toBackendError()
else -> BackendError.Unknown
}
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) {
tunStatusMutex.withLock {
activeTuns.update { current ->
val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
val newState = state ?: existingState.status
if (newState == TunnelStatus.Down) {
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
current - originalConf
} else if (existingState.status == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
current
} else {
val updated = existingState.copy(
status = newState,
statistics = stats ?: existingState.statistics,
)
current + (originalConf to updated)
private fun updateTunnelState(tunnelConf: TunnelConf, state: TunnelStatus? = null, stats: TunnelStatistics? = null) {
applicationScope.launch(ioDispatcher) {
mutex.withLock {
activeTuns.update { current ->
val originalConf = current.getKeyById(tunnelConf.id) ?: tunnelConf
val existingState = current.getValueById(tunnelConf.id) ?: TunnelState()
val newState = state ?: existingState.state
if (newState == TunnelStatus.DOWN) {
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
current - originalConf
} else if (existingState.state == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
current
} else {
val updated = existingState.copy(
state = newState,
statistics = stats ?: existingState.statistics,
)
current + (originalConf to updated)
}
}
}
}
}
private suspend fun stopActiveTunnels() {
protected suspend fun stopActiveTunnels() {
activeTunnels.value.forEach { (config, state) ->
if (state.status.isUpOrStarting()) {
if (state.state.isUp()) {
stopTunnel(config)
delay(300)
}
@@ -90,49 +120,40 @@ abstract class BaseTunnel(
tunnelConf.setStateChangeCallback { state ->
Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}")
when (state) {
is Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) }
is org.amnezia.awg.backend.Tunnel.State -> applicationScope.launch { updateTunnelStatus(tunnelConf, state.asTunnelState()) }
is Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf, state.asTunnelState())
}
serviceManager.updateTunnelTile()
}
tunnelConf.setTunnelStatsCallback {
val stats = getStatistics(tunnelConf)
updateTunnelState(tunnelConf, null, stats)
}
tunnelConf.setBounceTunnelCallback(::bounceTunnel)
}
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
val stats = getStatistics(tunnel)
updateTunnelStatus(tunnel, null, stats)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
if (activeTuns.exists(tunnelConf.id) || tunThreads.containsKey(tunnelConf.id)) return
// stop active tunnels if we are userspace
if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels()
tunMutex.withLock {
// use thread to interrupt java backend if stuck (like in dns resolution)
tunThreads += tunnelConf.id to thread {
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")
}
}
override fun startTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
runCatching {
if (isTunnelActive(tunnelConf.id)) return@launch
startTunnelInner(tunnelConf)
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
}
}
}
private suspend fun startTunnelInner(tunnelConf: TunnelConf) {
configureTunnelCallbacks(tunnelConf)
Timber.d("Started backend for tunnel ${tunnelConf.id}...")
startBackend(tunnelConf)
updateTunnelStatus(tunnelConf, TunnelStatus.Up)
Timber.d("DONE for tun ${tunnelConf.id}...")
saveTunnelActiveState(tunnelConf, true)
serviceManager.startTunnelForegroundService()
mutex.withLock {
configureTunnelCallbacks(tunnelConf)
startBackend(tunnelConf)
saveTunnelActiveState(tunnelConf, true)
if (!isBounce.get()) serviceManager.startTunnelForegroundService(tunnelConf)
}
}
private suspend fun saveTunnelActiveState(tunnelConf: TunnelConf, active: Boolean) {
@@ -140,50 +161,35 @@ abstract class BaseTunnel(
appDataRepository.tunnels.save(tunnelCopy)
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) {
if (tunnelConf == null) return stopActiveTunnels()
tunMutex.withLock {
try {
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))
override fun stopTunnel(tunnelConf: TunnelConf?) {
applicationScope.launch(ioDispatcher) {
runCatching {
if (tunnelConf == null) return@launch stopActiveTunnels()
stopTunnelInner(tunnelConf)
} catch (e: BackendError) {
Timber.e(e, "Failed to stop tunnel ${tunnelConf.id}")
updateTunnelStatus(tunnelConf, TunnelStatus.Error(e))
}.onFailure { e ->
Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}")
}
}
}
private suspend fun stopTunnelInner(tunnelConf: TunnelConf) {
val tunnel = activeTuns.findTunnel(tunnelConf.id) ?: return
stopBackend(tunnel)
saveTunnelActiveState(tunnelConf, false)
removeActiveTunnel(tunnel)
handleServiceChangesOnStop()
}
private suspend fun handleServiceChangesOnStop() {
if (activeTuns.value.isEmpty() && !isBouncing.get()) return serviceManager.stopTunnelForegroundService()
}
private suspend fun handleTunnelThreadCleanup(tunnel: TunnelConf) {
Timber.d("Cleaning up thread for ${tunnel.name}")
try {
tunThreads[tunnel.id]?.let {
if (it.state != Thread.State.TERMINATED) {
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}")
mutex.withLock {
val tunnel = findActiveTunnel(tunnelConf.id) ?: return
stopBackend(tunnel)
removeActiveTunnel(tunnel)
// use latest tunnel
saveTunnelActiveState(tunnelConf, false)
handleServiceChangesOnStop()
}
}
private fun handleServiceChangesOnStop() {
if (activeTuns.value.isEmpty() && !isBounce.get()) return serviceManager.stopTunnelForegroundService()
val nextActive = activeTuns.value.keys.firstOrNull()
if (nextActive != null) {
Timber.d("Next active tunnel: ${nextActive.id}")
serviceManager.updateTunnelForegroundServiceNotification(nextActive)
}
Timber.d("Removing thread for ${tunnel.name}")
tunThreads -= tunnel.id
}
private fun removeActiveTunnel(tunnelConf: TunnelConf) {
@@ -192,12 +198,15 @@ abstract class BaseTunnel(
}
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
Timber.i("Bounce tunnel ${tunnelConf.name}")
isBouncing.set(true)
stopTunnel(tunnelConf, reason)
startTunnel(tunnelConf)
isBouncing.set(false)
override fun bounceTunnel(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
Timber.i("Bounce tunnel ${tunnelConf.name}")
isBounce.set(true)
stopTunnel(tunnelConf)
delay(300)
startTunnel(tunnelConf)
isBounce.set(false)
}
}
override suspend fun runningTunnelNames(): Set<String> = activeTuns.value.keys.map { it.tunName }.toSet()
@@ -1,16 +1,14 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import kotlinx.coroutines.flow.MutableStateFlow
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 {
return this.any { it.value.status.isUp() }
return this.any { it.value.state.isUp() }
}
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 {
return this.getValueById(tunnelConf.id)?.status?.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)
return this.getValueById(tunnelConf.id)?.state?.isUp() ?: false
}
@@ -1,18 +1,16 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
@@ -23,8 +21,9 @@ class KernelTunnel @Inject constructor(
@ApplicationScope private val applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
private val backend: Backend,
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) {
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) {
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return try {
@@ -36,31 +35,18 @@ class KernelTunnel @Inject constructor(
}
override suspend fun startBackend(tunnel: TunnelConf) {
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
}
override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.id} kernel")
try {
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig())
} catch (e: BackendException) {
throw e.toBackendError()
}
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toWgConfig())
}
override fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.w("Not yet implemented for kernel")
}
override fun getBackendState(): BackendState {
return BackendState.INACTIVE
}
override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames
}
@@ -6,7 +6,6 @@ import com.zaneschepke.wireguardautotunnel.di.Kernel
import com.zaneschepke.wireguardautotunnel.di.Userspace
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.CoroutineDispatcher
@@ -57,38 +56,22 @@ class TunnelManager @Inject constructor(
initialValue = emptyMap(),
)
override fun hasVpnPermission(): Boolean {
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) {
override fun startTunnel(tunnelConf: TunnelConf) {
tunnelProviderFlow.value.startTunnel(tunnelConf)
}
override suspend fun stopTunnel(tunnelConf: TunnelConf?, reason: TunnelStatus.StopReason) {
override fun stopTunnel(tunnelConf: TunnelConf?) {
tunnelProviderFlow.value.stopTunnel(tunnelConf)
}
override suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
override fun bounceTunnel(tunnelConf: 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)
}
override fun getBackendState(): BackendState {
return tunnelProviderFlow.value.getBackendState()
}
override suspend fun runningTunnelNames(): Set<String> {
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.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import kotlinx.coroutines.flow.StateFlow
interface TunnelProvider {
suspend fun startTunnel(tunnelConf: TunnelConf)
suspend fun stopTunnel(tunnelConf: TunnelConf? = null, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER)
suspend fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER)
fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
fun getBackendState(): BackendState
fun startTunnel(tunnelConf: TunnelConf)
fun stopTunnel(tunnelConf: TunnelConf? = null)
fun bounceTunnel(tunnelConf: TunnelConf)
suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
suspend fun runningTunnelNames(): Set<String>
fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics?
val activeTunnels: StateFlow<Map<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
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.BackendException
import org.amnezia.awg.backend.Tunnel
import org.amnezia.awg.config.Config
import timber.log.Timber
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
class UserspaceTunnel @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
val serviceManager: ServiceManager,
val appDataRepository: AppDataRepository,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
private val backend: Backend,
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager) {
private var previousBackendState: Pair<BackendState, Boolean>? = null
) : BaseTunnel(ioDispatcher, applicationScope, appDataRepository, serviceManager, notificationManager) {
override suspend fun startBackend(tunnel: TunnelConf) {
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
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()
}
stopActiveTunnels()
backend.setState(tunnel, Tunnel.State.UP, tunnel.toAmConfig())
}
override fun stopBackend(tunnel: TunnelConf) {
Timber.i("Stopping tunnel ${tunnel.name} userspace")
try {
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig())
} catch (e: BackendException) {
Timber.e(e, "Failed to stop tunnel ${tunnel.id}")
throw e.toBackendError()
}
handlePreviouslyEnabledVpnKillSwitch()
Timber.i("Stopping tunnel ${tunnel.id} userspace")
backend.setState(tunnel, Tunnel.State.DOWN, tunnel.toAmConfig())
}
// stop vpn kill switch if we need to resolve DNS for peer endpoints
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>) {
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps")
try {
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
} catch (e: BackendException) {
throw e.toBackendError()
}
}
override fun getBackendState(): BackendState {
return backend.backendState.asBackendState()
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
}
override suspend fun runningTunnelNames(): Set<String> {
@@ -52,7 +52,7 @@ class ServiceWorker @AssistedInject constructor(
override suspend fun doWork(): Result = withContext(ioDispatcher) {
Timber.i("Service worker started")
with(appDataRepository.settings.get()) {
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel()
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel(true)
if (tunnelManager.activeTunnels.value.isEmpty()) tunnelManager.restorePreviousState()
}
Result.success()
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters {
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import javax.inject.Inject
class AppDataRoomRepository
@@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
@@ -30,12 +30,6 @@ class RoomTunnelRepository(
}
}
override suspend fun saveAll(tunnelConfs: List<TunnelConf>) {
withContext(ioDispatcher) {
tunnelConfigDao.saveAll(tunnelConfs.map(TunnelConfig::from))
}
}
override suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?) {
withContext(ioDispatcher) {
tunnelConfigDao.resetPrimaryTunnel()
@@ -4,17 +4,17 @@ import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.Module
import dagger.Provides
@@ -6,6 +6,7 @@ import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
@@ -66,9 +67,10 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
backend: com.wireguard.android.backend.Backend,
): TunnelProvider {
return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend)
return KernelTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend)
}
@Provides
@@ -79,9 +81,10 @@ class TunnelModule {
@ApplicationScope applicationScope: CoroutineScope,
serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
notificationManager: NotificationManager,
backend: Backend,
): TunnelProvider {
return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, backend)
return UserspaceTunnel(ioDispatcher, applicationScope, serviceManager, appDataRepository, notificationManager, backend)
}
@Provides
@@ -107,10 +110,9 @@ class TunnelModule {
fun provideServiceManager(
@ApplicationContext context: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@MainDispatcher mainCoroutineDispatcher: CoroutineDispatcher,
@ApplicationScope applicationScope: CoroutineScope,
appDataRepository: AppDataRepository,
): ServiceManager {
return ServiceManager(context, ioDispatcher, applicationScope, mainCoroutineDispatcher, appDataRepository)
return ServiceManager(context, ioDispatcher, applicationScope, appDataRepository)
}
}
@@ -3,9 +3,6 @@ package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.defaultName
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.withContext
@@ -32,28 +29,22 @@ data class TunnelConf(
val isIpv4Preferred: Boolean = true,
@Transient
private var stateChangeCallback: ((Any) -> Unit)? = null,
@Transient
private var tunnelStatsCallback: (() -> Unit)? = null,
@Transient
private var bounceTunnelCallback: ((TunnelConf) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
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
fun setTunnelStatsCallback(callback: (() -> Unit)) {
tunnelStatsCallback = callback
}
override fun hashCode(): Int {
var result = id
result = 31 * result + tunName.hashCode()
result = 31 * result + wgQuick.hashCode()
result = 31 * result + amQuick.hashCode()
return result
fun setBounceTunnelCallback(callback: (TunnelConf) -> Unit) {
bounceTunnelCallback = callback
}
fun copyWithCallback(
@@ -78,18 +69,18 @@ data class TunnelConf(
isEthernetTunnel, isIpv4Preferred,
).apply {
stateChangeCallback = this@TunnelConf.stateChangeCallback
// tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
// bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
tunnelStatsCallback = this@TunnelConf.tunnelStatsCallback
bounceTunnelCallback = this@TunnelConf.bounceTunnelCallback
}
}
// fun onUpdateStatistics() {
// tunnelStatsCallback?.invoke()
// }
//
// fun bounceTunnel(tunnelConf: TunnelConf, reason: TunnelStatus.StopReason) {
// bounceTunnelCallback?.invoke(tunnelConf, reason)
// }
fun onUpdateStatistics() {
tunnelStatsCallback?.invoke()
}
fun bounceTunnel(tunnelConf: TunnelConf) {
bounceTunnelCallback?.invoke(tunnelConf)
}
fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(amQuick.ifBlank { wgQuick })
@@ -115,21 +106,6 @@ data class TunnelConf(
return updatedConf.wgQuick != wgQuick || updatedConf.amQuick != amQuick || updatedConf.name != name
}
fun generateUniqueName(tunnelNames: List<String>): String {
var tunnelName = this.tunName
var num = 1
while (tunnelNames.any { it == tunnelName }) {
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
"$name($num)"
} else {
val pair = tunnelName.extractNameAndNumber()
"${pair?.first}($num)"
}
num++
}
return tunnelName
}
suspend fun isTunnelPingable(context: CoroutineContext): Boolean {
return withContext(context) {
val config = toWgConfig()
@@ -162,10 +138,10 @@ data class TunnelConf(
}
}
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String? = null): TunnelConf {
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String): TunnelConf {
val amQuick = config.toAwgQuickString(true)
val wgQuick = config.toWgQuickString()
return TunnelConf(tunName = name ?: config.defaultName(), wgQuick = wgQuick, amQuick = amQuick)
return TunnelConf(tunName = name, wgQuick = wgQuick, amQuick = amQuick)
}
private const val IPV6_ALL_NETWORKS = "::/0"
@@ -1,24 +1,8 @@
package com.zaneschepke.wireguardautotunnel.domain.enums
import com.zaneschepke.wireguardautotunnel.R
sealed class BackendError : Exception() {
sealed class BackendError() {
data object DNS : BackendError()
data object Unauthorized : 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()
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
sealed class TunnelStatus {
data class Error(val error: BackendError) : TunnelStatus()
data object Up : TunnelStatus()
data object Down : TunnelStatus()
data class Stopping(val reason: StopReason) : TunnelStatus()
data object Starting : TunnelStatus()
enum class StopReason {
USER,
PING,
CONFIG_CHANGED,
}
enum class TunnelStatus {
UP,
DOWN,
STARTING,
STOPPING,
;
fun isDown(): Boolean {
return this == Down
return this == DOWN
}
fun isUp(): Boolean {
return this == Up
}
fun isUpOrStarting(): Boolean {
return this == Up || this == Starting
}
fun isDownOrStopping(): Boolean {
return this == Down || this is Stopping
return this == UP
}
}
@@ -11,8 +11,6 @@ interface TunnelRepository {
suspend fun save(tunnelConf: TunnelConf)
suspend fun saveAll(tunnelConfList: List<TunnelConf>)
suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?)
suspend fun updateMobileDataTunnel(tunnelConf: TunnelConf?)
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
@@ -4,7 +4,7 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
data class TunnelState(
val status: TunnelStatus = TunnelStatus.Down,
val state: TunnelStatus = TunnelStatus.DOWN,
val backendState: BackendState = BackendState.INACTIVE,
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.layout.Spacer
@@ -1,59 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun <T> DropdownSelector(
currentValue: T,
options: List<T>,
onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
isExpanded: Boolean = false,
onDismiss: () -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (label != null) label()
Text(
text = currentValue.toString(),
style = MaterialTheme.typography.bodyMedium,
)
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
}
DropdownMenu(
modifier = modifier.height(250.dp),
scrollState = rememberScrollState(),
containerColor = MaterialTheme.colorScheme.surface,
expanded = isExpanded,
onDismissRequest = onDismiss,
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(text = option.toString()) },
onClick = {
onValueSelected(option)
onDismiss() // Close dropdown after selection
},
)
}
}
}
@@ -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.Row
@@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
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
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(
modifier =
Modifier
.padding(horizontal = 8.dp, vertical = 10.dp)
.padding(end = 16.dp).padding(start = 8.dp)
.padding(horizontal = 8.dp.scaledWidth(), vertical = 10.dp.scaledHeight())
.padding(end = 16.dp.scaledWidth()).padding(start = 8.dp.scaledWidth())
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp.scaledWidth()),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
16.dp,
16.dp.scaledWidth(),
),
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 {
Icon(
@@ -7,13 +7,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun ScaledSwitch(checked: Boolean, onClick: (checked: Boolean) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) {
Switch(
checked,
{ onClick(it) },
modifier.scale((52.dp / 52.dp)),
modifier.scale((52.dp.scaledHeight() / 52.dp)),
enabled = enabled,
colors = SwitchDefaults.colors().copy(
checkedThumbColor = MaterialTheme.colorScheme.background,
@@ -19,9 +19,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SelectionItemButton(
@@ -30,21 +31,20 @@ fun SelectionItemButton(
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
ripple: Boolean = true,
modifier: Modifier = Modifier,
) {
Card(
modifier =
modifier
Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(
indication = if (ripple) ripple() else null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onClick() },
)
.height(56.dp),
.height(56.dp.scaledHeight()),
colors =
CardDefaults.cardColors(
containerColor = Color.Transparent,
containerColor = MaterialTheme.colorScheme.background,
),
) {
Row(
@@ -52,7 +52,7 @@ fun SelectionItemButton(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxSize()
.padding(end = 10.dp),
.padding(end = 10.dp.scaledWidth()),
) {
leading?.let {
it()
@@ -1,20 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@Composable
fun SelectionItemLabel(
textResId: Int,
style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium,
isDescription: Boolean = false,
) {
Text(
text = stringResource(textResId),
style = style.copy(
color = if (isDescription) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface,
),
)
}
@@ -19,6 +19,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
@@ -36,12 +38,12 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp)
.padding(start = 16.dp.scaledWidth())
.weight(4f, false)
.fillMaxWidth(),
) {
@@ -58,8 +60,8 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = if (item.leadingIcon != null) 16.dp else 0.dp)
.padding(vertical = if (item.description == null) 16.dp else 6.dp),
.padding(start = if (item.leadingIcon != null) 16.dp.scaledWidth() else 0.dp)
.padding(vertical = if (item.description == null) 16.dp.scaledHeight() else 6.dp.scaledHeight()),
) {
item.title()
item.description?.let {
@@ -71,7 +73,7 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.padding(end = 24.dp, start = 16.dp)
.padding(end = 24.dp.scaledWidth(), start = 16.dp.scaledWidth())
.weight(1f),
) {
it()
@@ -35,9 +35,9 @@ fun ConfigurationTextBox(
singleLine = true,
interactionSource = interactionSource,
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,
placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.bodyMedium) },
placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.labelLarge) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
trailingIcon = trailing,
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SubmitConfigurationTextBox(
@@ -58,7 +59,7 @@ fun SubmitConfigurationTextBox(
.padding(
top = 5.dp,
bottom = 10.dp,
).fillMaxWidth().padding(end = 16.dp),
).fillMaxWidth().padding(end = 16.dp.scaledWidth()),
singleLine = true,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
@@ -1,14 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.zaneschepke.wireguardautotunnel.R
@Composable
@@ -19,32 +15,23 @@ fun InfoDialog(
body: @Composable () -> Unit,
confirmText: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(),
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(onClick = { onAttest() }) {
confirmText()
}
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(
onClick = {
onAttest()
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(text = stringResource(R.string.cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface,
title = { title() },
text = { body() },
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
)
}
}
) {
confirmText()
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { title() },
text = { body() },
)
}
@@ -6,7 +6,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.font.FontWeight
@Composable
fun GroupLabel(title: String) {
@@ -17,7 +16,6 @@ fun GroupLabel(title: String) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
package com.zaneschepke.wireguardautotunnel.ui.common.label
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -13,7 +13,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.Constants
@Composable
fun VersionLabel() {
@@ -22,13 +21,8 @@ fun VersionLabel() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
val versionText = if (BuildConfig.BUILD_TYPE == Constants.RELEASE) {
BuildConfig.VERSION_NAME
} else {
"${BuildConfig.VERSION_NAME}-${BuildConfig.BUILD_TYPE}"
}
Text(
"${stringResource(R.string.version)}: $versionText",
"${stringResource(R.string.version)}: ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.clickable {
@@ -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 route: Route,
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.material3.MaterialTheme
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
@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.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
@@ -19,20 +18,33 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val isBiometricAvailable =
remember {
when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
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
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError(context.getString(R.string.bio_update_required))
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, BiometricManager.BIOMETRIC_STATUS_UNKNOWN,
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
-> {
onError(context.getString(R.string.bio_not_supported))
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
@@ -46,8 +58,8 @@ fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle(context.getString(R.string.bio_auth_title))
.setSubtitle(context.getString(R.string.bio_subtitle))
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential")
.build()
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,48 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.DropdownSelector
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) {
var isDropDownExpanded by remember { mutableStateOf(false) }
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.PauseCircle,
title = {
Text(
stringResource(R.string.debounce_delay),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
)
},
onClick = { isDropDownExpanded = true },
trailing = {
DropdownSelector(
currentValue = currentDelay,
options = (0..10).toList(),
onValueSelected = { num -> onEvent(AppEvent.SetDebounceDelay(num)) },
isExpanded = isDropDownExpanded,
onDismiss = { isDropDownExpanded = false },
)
},
),
),
)
}
@@ -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
}
}
@@ -2,63 +2,132 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
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.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.MainViewModel
import java.text.Collator
import java.util.Locale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) {
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) {
val context = LocalContext.current
val navController = LocalNavController.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 selectedTunnel by remember { mutableStateOf<TunnelConf?>(null) }
var showUrlImportDialog by remember { mutableStateOf(false) }
val isRunningOnTv = remember { context.isRunningOnTv() }
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
onNoFileExplorer = { viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_no_file_explorer))) },
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
)
val activeTunnels by viewModel.tunnelManager.activeTunnels.collectAsStateWithLifecycle(emptyMap())
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.camera_permission_required)),
)
return@rememberLauncherForActivityResult
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(uiState.tunnels) {
uiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
}
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
val startTunnel = withVpnPermission<TunnelConf> {
viewModel.onTunnelStart(it)
}
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
if (uiState.appSettings.isKernelEnabled) {
viewModel.onToggleAutoTunnel()
} else {
startAutoTunnel.invoke(Unit)
}
}
val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
snackbar.showMessage(
context.getString(R.string.error_no_file_explorer),
)
}, onData = { data ->
viewModel.onTunnelFileSelected(data, context)
})
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
navController.navigate(Route.Scanner)
}
if (showDeleteTunnelAlertDialog && appViewState.selectedTunnel != null) {
if (showDeleteTunnelAlertDialog) {
InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = {
appViewState.selectedTunnel.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
selectedTunnel?.let { viewModel.onDelete(it) }
showDeleteTunnelAlertDialog = false
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
selectedTunnel = null
},
title = { Text(text = stringResource(R.string.delete_tunnel)) },
body = { Text(text = stringResource(R.string.delete_tunnel_message)) },
@@ -66,46 +135,127 @@ fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: Ap
)
}
TunnelImportSheet(
appViewState.showBottomSheet,
onDismiss = { viewModel.handleEvent(AppEvent.ToggleBottomSheet) },
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) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false
},
)
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConf) {
if (!checked) return viewModel.onTunnelStop(tunnel).let { }
if (uiState.appSettings.isKernelEnabled) viewModel.onTunnelStart(tunnel) else startTunnel(tunnel)
}
TunnelList(
appUiState = appUiState,
activeTunnels = appUiState.activeTunnels,
selectedTunnel = appViewState.selectedTunnel,
onSetSelectedTunnel = { viewModel.handleEvent(AppEvent.SetSelectedTunnel(it)) },
onDeleteTunnel = {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(it))
showDeleteTunnelAlertDialog = true
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
if (uiState.tunnels.isEmpty()) return@pointerInput
detectTapGestures(
onTap = {
selectedTunnel = null
},
)
},
onToggleTunnel = { tunnel, checked ->
if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
if (!isRunningOnTv) {
ScrollDismissFab({
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, isVisible = isFabVisible, onClick = {
showBottomSheet = true
})
}
},
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = {
viewModel.handleEvent(AppEvent.CopyTunnel(it))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
topBar = {
if (isRunningOnTv) {
TopNavBar(
showBack = false,
title = stringResource(R.string.app_name),
trailing = {
IconButton(onClick = {
showBottomSheet = true
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
},
)
}
},
modifier = Modifier
.fillMaxSize()
.padding(top = 12.dp, bottom = 24.dp).padding(horizontal = 12.dp),
viewModel = viewModel,
)
) { 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.onClipboardImport(it)
}
},
onManualImportClick = {
navController.navigate(
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
)
},
onUrlClick = { showUrlImportDialog = true }
)
if (showUrlImportDialog) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.onUrlImport(url)
showUrlImportDialog = false
}
)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding).padding(top = 24.dp.scaledHeight())
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (uiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
} else {
item {
AutoTunnelRowItem(uiState) {
autoTunnelToggleBattery.invoke()
}
}
}
items(
sortedTunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
tunnelState.state.isUp(),
expanded,
selectedTunnel?.id == tunnel.id,
tunnel,
tunnelState = tunnelState,
{ selectedTunnel = tunnel },
{ viewModel.onExpandedChanged(!expanded) },
onDelete = { showDeleteTunnelAlertDialog = true },
onCopy = { viewModel.onCopyTunnel(tunnel) },
onSwitchClick = { onTunnelToggle(it, tunnel) },
)
}
}
}
}
@@ -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.Text
@@ -6,18 +6,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinLock
@Composable
fun PinLockScreen(viewModel: AppViewModel) {
fun PinLockScreen(appViewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val snackbar = SnackbarController.current
PinLock(
title = { pinExists ->
Text(
@@ -47,13 +48,16 @@ fun PinLockScreen(viewModel: AppViewModel) {
},
onPinIncorrect = {
// 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 = {
// pin created for the first time, navigate or hide pin lock
viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.pin_created)))
viewModel.handleEvent(AppEvent.TogglePinLock)
snackbar.showMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
appViewModel.onPinLockEnabled()
},
)
}
@@ -1,18 +1,28 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.journeyapps.barcodescanner.CompoundBarcodeView
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.viewmodel.ScannerViewModel
@Composable
fun ScannerScreen(viewModel: AppViewModel) {
fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
val context = LocalContext.current
val navController = LocalNavController.current
val success = viewModel.success.collectAsStateWithLifecycle(null)
LaunchedEffect(success.value) {
if (success.value != null) navController.popBackStack()
}
val barcodeView = remember {
CompoundBarcodeView(context).apply {
@@ -20,7 +30,7 @@ fun ScannerScreen(viewModel: AppViewModel) {
this.setStatusText("")
this.decodeSingle { result ->
result.text?.let { barCodeOrQr ->
viewModel.handleEvent(AppEvent.ImportTunnelFromQrCode(barCodeOrQr))
viewModel.onTunnelQrResult(barCodeOrQr)
}
}
}
@@ -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 androidx.hilt.navigation.compose.hiltViewModel
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.TunnelAutoTunnelViewModel
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tunnelAutoTunnelViewModel: TunnelAutoTunnelViewModel = hiltViewModel()) {
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 = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(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 = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(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 = { tunnelAutoTunnelViewModel.onDeleteRunSSID(it, tunnelConf) },
currentText = currentText,
onSave = { tunnelAutoTunnelViewModel.onSaveRunSSID(it, tunnelConf) },
onValueChange = { currentText = it },
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
),
)
},
)
}
}
}
@@ -0,0 +1,256 @@
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 androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelOptionsViewModel
import kotlin.text.isBlank
import kotlin.text.isNullOrBlank
import kotlin.text.toLong
@Composable
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: TunnelOptionsViewModel = hiltViewModel()) {
val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
val onPingToggle = {
viewModel.saveTunnel(tunnelConf.copy(isPingEnabled = !tunnelConf.isPingEnabled))
}
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.onTogglePrimaryTunnel(tunnelConf) },
)
},
onClick = { viewModel.onTogglePrimaryTunnel(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.onToggleIpv4(tunnelConf) },
)
},
onClick = { viewModel.onToggleIpv4(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 = { onPingToggle() },
)
},
onClick = { onPingToggle() },
),
)
if (tunnelConf.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConf.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
viewModel.saveTunnel(
tunnelConf.copy(pingIp = it.ifBlank { null }),
)
},
)
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 = {
viewModel.onPingIntervalChange(tunnelConf, it)
},
)
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 = { viewModel.onPingCoolDownChange(tunnelConf, it) },
)
},
),
)
}
},
)
}
}
}
@@ -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,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.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
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,
)
}
@@ -8,12 +8,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPasteGo
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Link
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.material3.rememberModalBottomSheetState
@@ -42,7 +41,6 @@ fun TunnelImportSheet(
val context = LocalContext.current
if (show) {
ModalBottomSheet(
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = {
onDismiss()
},
@@ -1,80 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import java.text.Collator
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TunnelList(
appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>,
selectedTunnel: TunnelConf?,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit,
modifier: Modifier = Modifier,
viewModel: AppViewModel,
) {
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(appUiState.tunnels) {
appUiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier = modifier
.pointerInput(Unit) {
if (appUiState.tunnels.isEmpty()) return@pointerInput
}.overscroll(ScrollableDefaults.overscrollEffect()),
state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (appUiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
}
items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
isActive = tunnelState.status.isUpOrStarting(),
expanded = appUiState.generalState.isTunnelStatsExpanded,
isSelected = selectedTunnel?.id == tunnel.id,
tunnel = tunnel,
tunnelState = tunnelState,
onSetSelectedTunnel = { onSetSelectedTunnel(it) },
onClick = onExpandStats,
onCopy = { onCopyTunnel(tunnel) },
onDelete = { onDeleteTunnel(tunnel) },
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
viewModel = viewModel,
)
}
}
}
@@ -1,8 +1,7 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
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.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
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.button.ScaledSwitch
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.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun TunnelRowItem(
@@ -46,174 +42,153 @@ fun TunnelRowItem(
isSelected: Boolean,
tunnel: TunnelConf,
tunnelState: TunnelState,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onHold: () -> Unit,
onClick: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
onSwitchClick: (Boolean) -> Unit,
viewModel: AppViewModel,
onSwitchClick: (checked: Boolean) -> Unit,
) {
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val context = LocalContext.current
val snackbar = SnackbarController.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val (leadingIcon, size) = when {
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp)
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp)
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp)
else -> Pair(Icons.Rounded.Circle, 14.dp)
}
ExpandingRowListItem(
leading = {
val icon = when {
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
else -> Icons.Rounded.Circle
}
Icon(
leadingIcon,
stringResource(R.string.status),
icon,
icon.name,
tint = leadingIconColor,
modifier = Modifier.size(size),
modifier = Modifier.size(16.dp),
)
},
text = tunnel.tunName,
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onSetSelectedTunnel(tunnel)
onHold()
},
onClick = {
if (!isTv) {
if (isActive) onClick()
if (!context.isRunningOnTv()) {
if (isActive) {
onClick()
}
} else {
onSetSelectedTunnel(tunnel)
onHold()
itemFocusRequester.requestFocus()
}
},
isExpanded = expanded && isActive,
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
trailing = {
if (isSelected && !isTv) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
if (
isSelected &&
!context.isRunningOnTv()
) {
Row {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onSetSelectedTunnel(null)
navController.navigate(Route.TunnelOptions(tunnel.id))
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onCopy()
onSetSelectedTunnel(null)
},
modifier = Modifier.focusable(),
onClick = { onCopy() },
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
modifier = Modifier.weight(1f),
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { onDelete() },
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else if (isTv) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
onClick()
} else {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_on_tunnel)),
} else {
if (context.isRunningOnTv()) {
Row {
IconButton(
onClick = {
onHold()
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
}
},
) {
Icon(
Icons.Rounded.Info,
stringResource(R.string.info),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = onCopy,
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_off_tunnel)),
)
} else {
onDelete()
}
},
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
onClick = {
if (isActive) {
onClick()
} else {
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (isActive) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
onHold()
onDelete()
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
} else {
ScaledSwitch(
modifier = Modifier
.focusRequester(itemFocusRequester)
.weight(1f),
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
},
)
@@ -18,38 +18,41 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun UrlImportDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var url by remember { mutableStateOf("") }
fun UrlImportDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var url by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_from_url)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.enter_config_url)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(url) },
enabled = url.isNotBlank(),
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_from_url)) },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.enter_config_url)) },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(url) },
enabled = url.isNotBlank()
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@@ -1,78 +1,55 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.AddPeerButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.InterfaceSection
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components.PeersSection
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel: ConfigViewModel = hiltViewModel()) {
fun ConfigScreen(tunnelConf: TunnelConf?, viewModel: ConfigViewModel = hiltViewModel()) {
val context = LocalContext.current
val snackbar = SnackbarController.current
val keyboardController = LocalSoftwareKeyboardController.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val activity = context as? MainActivity
// Secure screen due to sensitive information
DisposableEffect(Unit) {
activity?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
onDispose {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
LaunchedEffect(Unit) {
// set callback for navbar to invoke save
appViewModel.handleEvent(
AppEvent.SetScreenAction {
keyboardController?.hide()
viewModel.save(tunnelConf)
},
)
}
LaunchedEffect(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) {
uiState.message?.let { message ->
appViewModel.handleEvent(AppEvent.ShowMessage(message))
snackbar.showMessage(message.asString(context))
viewModel.setMessage(null)
}
}
@@ -85,24 +62,47 @@ fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel:
},
onError = {
viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authentication_failed)))
snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
},
onFailure = {
viewModel.toggleShowAuthPrompt()
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_authorization_failed)))
snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
},
)
}
Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
InterfaceSection(uiState, viewModel)
PeersSection(uiState, viewModel)
AddPeerButton(viewModel)
Scaffold(
topBar = {
TopNavBar(
title = stringResource(R.string.edit_tunnel),
trailing = {
IconButton(onClick = {
keyboardController?.hide()
viewModel.save(tunnelConf)
}) {
Icon(Icons.Outlined.Save, contentDescription = stringResource(R.string.save))
}
},
)
},
) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
InterfaceSection(uiState, viewModel)
PeersSection(uiState, viewModel)
AddPeerButton(viewModel)
}
}
}
@@ -81,7 +81,7 @@ class ConfigViewModel @Inject constructor(
}
_uiState.update {
it.copy(
showAmneziaValues = show,
showScripts = show,
configProxy = it.configProxy.copy(
`interface` = `interface`,
),
@@ -136,14 +136,13 @@ class ConfigViewModel @Inject constructor(
val message = try {
val saveConfig = buildTunnelConfFromState(tunnelConf)
tunnelRepository.save(saveConfig)
_uiState.update { it.copy(success = true) }
StringValue.StringResource(R.string.config_changes_saved)
} catch (e: Exception) {
setMessage(
e.message?.let { message ->
(StringValue.DynamicString(message))
} ?: StringValue.StringResource(R.string.unknown_error),
)
e.message?.let { message ->
(StringValue.DynamicString(message))
} ?: StringValue.StringResource(R.string.unknown_error)
}
setMessage(message)
}
private fun buildTunnelConfFromState(tunnelConf: TunnelConf?): TunnelConf {
@@ -19,7 +19,7 @@ fun AddPeerButton(viewModel: ConfigViewModel) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 140.dp),
) {
TextButton(onClick = { viewModel.addPeer() }) {
Text(stringResource(R.string.add_peer))
@@ -45,7 +45,7 @@ fun InterfaceFields(
OutlinedTextField(
value = interfaceState.privateKey,
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 {
if (!isAuthenticated) showAuthPrompt()
},
@@ -75,7 +75,7 @@ fun InterfaceFields(
OutlinedTextField(
value = interfaceState.publicKey,
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,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -105,7 +105,7 @@ fun InterfaceFields(
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
ConfigurationTextBox(
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.screens.main.config.ConfigViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.state.ConfigUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
@@ -33,8 +34,8 @@ fun InterfaceSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
color = MaterialTheme.colorScheme.surface,
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(16.dp).focusGroup(),
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,8 +37,8 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
value = peer.preSharedKey,
enabled = isAuthenticated,
onValueChange = { onPeerChange(peer.copy(preSharedKey = it)) },
label = { Text(stringResource(R.string.preshared_key), style = MaterialTheme.typography.bodyMedium) },
placeholder = { Text(stringResource(R.string.optional), style = MaterialTheme.typography.bodyMedium) },
label = { Text(stringResource(R.string.preshared_key)) },
placeholder = { Text(stringResource(R.string.optional)) },
modifier = Modifier.fillMaxWidth().clickable { if (!isAuthenticated) showAuthPrompt() },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
@@ -48,9 +47,9 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
OutlinedTextField(
value = peer.persistentKeepalive,
onValueChange = { onPeerChange(peer.copy(persistentKeepalive = it)) },
label = { Text(stringResource(R.string.persistent_keepalive), style = MaterialTheme.typography.bodyMedium) },
placeholder = { Text(stringResource(R.string.optional), style = MaterialTheme.typography.bodyMedium) },
trailingIcon = { Text(stringResource(R.string.seconds), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 10.dp)) },
label = { Text(stringResource(R.string.persistent_keepalive)) },
placeholder = { Text(stringResource(R.string.optional_no_recommend)) },
trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
@@ -65,8 +64,8 @@ fun PeerFields(peer: PeerProxy, onPeerChange: (PeerProxy) -> Unit, showAuthPromp
OutlinedTextField(
value = peer.allowedIps,
onValueChange = { onPeerChange(peer.copy(allowedIps = it)) },
label = { Text(stringResource(R.string.allowed_ips), style = MaterialTheme.typography.bodyMedium) },
placeholder = { Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.bodyMedium) },
label = { Text(stringResource(R.string.allowed_ips)) },
placeholder = { Text(stringResource(R.string.comma_separated_list)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = keyboardOptions,
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.state.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
@@ -45,8 +46,8 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
color = MaterialTheme.colorScheme.surface,
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(16.dp).focusGroup(),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(16.dp.scaledWidth()).focusGroup(),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -62,14 +63,14 @@ fun PeersSection(uiState: ConfigUiState, viewModel: ConfigViewModel) {
modifier = Modifier.size(iconSize),
onClick = { viewModel.removePeer(index) },
) {
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
Icon(Icons.Rounded.Delete, contentDescription = "Delete")
}
Column {
IconButton(
modifier = Modifier.size(iconSize),
onClick = { isDropDownExpanded = true },
) {
Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.quick_actions))
Icon(Icons.Rounded.MoreVert, contentDescription = "More")
}
DropdownMenu(
expanded = isDropDownExpanded,
@@ -13,5 +13,4 @@ data class ConfigUiState(
val isAuthenticated: Boolean = true,
val showAuthPrompt: Boolean = false,
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.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components.SplitTunnelContent
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun SplitTunnelScreen(appViewModel: AppViewModel, viewModel: SplitTunnelViewModel = hiltViewModel()) {
fun SplitTunnelScreen(viewModel: SplitTunnelViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
appViewModel.handleEvent(AppEvent.SetScreenAction { viewModel.saveChanges() })
}
LaunchedEffect(uiState.success) {
if (uiState.success == true) {
appViewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.config_changes_saved)))
appViewModel.handleEvent(AppEvent.PopBackStack(true))
}
}
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,
Scaffold(
topBar = {
TopNavBar(
title = stringResource(R.string.tunneling_apps),
trailing = {
IconButton(onClick = { viewModel.saveChanges() }) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.save),
)
}
},
)
},
) { padding ->
Crossfade(
targetState = uiState.loading,
animationSpec = tween(200),
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { isLoading ->
if (isLoading) {
SplitTunnelSkeleton()
} else {
SplitTunnelContent(
uiState = uiState,
onSplitOptionChange = viewModel::updateSplitOption,
onAppSelectionToggle = viewModel::toggleAppSelection,
onQueryChange = viewModel::onSearchQuery,
)
}
}
}
}
@@ -22,22 +22,24 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.animation.ShimmerEffect
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun SplitTunnelSkeleton() {
val shimmerBrush = ShimmerEffect()
Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
.padding(top = 24.dp.scaledHeight()),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(horizontal = 24.dp.scaledWidth())
.height(45.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -55,7 +57,7 @@ fun SplitTunnelSkeleton() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(horizontal = 24.dp.scaledWidth())
.height(45.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -78,7 +80,7 @@ fun SplitTunnelSkeleton() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
.padding(horizontal = 24.dp.scaledWidth(), vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
@@ -4,14 +4,17 @@ import android.content.Context
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy
import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -151,7 +154,7 @@ class SplitTunnelViewModel @Inject constructor(
}
}
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) {
@@ -18,6 +18,7 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) {
@@ -36,7 +37,7 @@ fun AppListItem(appInfo: TunnelApp, isSelected: Boolean, onToggle: () -> Unit) {
painter = rememberDrawablePainter(icon),
contentDescription = appInfo.name,
modifier = Modifier
.padding(horizontal = 24.dp)
.padding(horizontal = 24.dp.scaledWidth())
.size(iconSize),
)
},
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.TunnelApp
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun AppListSection(apps: List<Pair<TunnelApp, Boolean>>, onAppSelectionToggle: (String) -> Unit, onQueryChange: (String) -> Unit, query: String) {
@@ -47,11 +48,11 @@ fun AppListSection(apps: List<Pair<TunnelApp, Boolean>>, onAppSelectionToggle: (
leading = {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
},
containerColor = MaterialTheme.colorScheme.surface,
containerColor = MaterialTheme.colorScheme.background,
modifier = Modifier
.fillMaxWidth()
.height(inputHeight)
.padding(horizontal = 24.dp),
.padding(horizontal = 24.dp.scaledWidth()),
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import java.util.*
@Composable
@@ -30,9 +31,9 @@ fun SplitOptionSelector(selectedOption: SplitOption, onOptionChange: (SplitOptio
MultiChoiceSegmentedButtonRow(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surface)
.background(color = MaterialTheme.colorScheme.background)
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(horizontal = 24.dp.scaledWidth())
.height(inputHeight),
) {
SplitOption.entries.forEachIndexed { index, entry ->
@@ -61,7 +62,7 @@ fun SplitOptionSelector(selectedOption: SplitOption, onOptionChange: (SplitOptio
},
colors = SegmentedButtonDefaults.colors().copy(
activeContainerColor = MaterialTheme.colorScheme.surface,
inactiveContainerColor = MaterialTheme.colorScheme.surface,
inactiveContainerColor = MaterialTheme.colorScheme.background,
),
onCheckedChange = { onOptionChange(entry) },
checked = active,
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitOption
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.state.SplitTunnelUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun SplitTunnelContent(
@@ -19,11 +20,11 @@ fun SplitTunnelContent(
onQueryChange: (String) -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
.padding(top = 24.dp.scaledHeight()),
) {
SplitOptionSelector(
selectedOption = uiState.splitOption,
@@ -8,5 +8,4 @@ data class SplitTunnelUiState(
val tunneledApps: SplitTunnelApps = emptyList(),
val splitOption: SplitOption = SplitOption.ALL,
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))
}
},
)
}
}

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