Compare commits

..

1 Commits

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 14:03:24 +00:00
160 changed files with 3385 additions and 4251 deletions
+6 -3
View File
@@ -86,6 +86,7 @@ android {
}
debug {
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,22 @@
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.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 +24,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 +36,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.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.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner.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.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.settings.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
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
@@ -110,7 +87,6 @@ class MainActivity : AppCompatActivity() {
private var lastLocationPermissionState: Boolean? = null
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.Companion.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@@ -125,47 +101,23 @@ class MainActivity : AppCompatActivity() {
installSplashScreen().apply {
setKeepOnScreenCondition {
!viewModel.appViewState.value.isAppReady
!viewModel.appState.value.isAppReady
}
}
setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
val appState by viewModel.appState.collectAsStateWithLifecycle()
if (!appState.isAppReady) {
Box(modifier = Modifier.fillMaxSize())
return@setContent
}
val navController = rememberNavController()
val 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 snackbarController = SnackbarController.current
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))
}
}
val batteryActivity = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { _: ActivityResult ->
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
with(appViewState) {
with(appState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
Intent(this@MainActivity, MainActivity::class.java).also {
@@ -176,193 +128,128 @@ class MainActivity : AppCompatActivity() {
}
LaunchedEffect(errorMessage) {
errorMessage?.let {
snackbar.showSnackbar(it.asString(this@MainActivity))
viewModel.handleEvent(AppEvent.MessageShown)
snackbarController.showMessage(it.asString(this@MainActivity))
}
}
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}")
},
)
viewModel.handleEvent(AppEvent.BackStackPopped)
}
}
}
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,
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(appUiState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(appUiState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(appUiState, viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(appUiState.appSettings, viewModel)
}
composable<Route.Appearance> {
AppearanceScreen()
}
composable<Route.Language> {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState, viewModel)
}
composable<Route.Support> {
SupportScreen(appUiState, viewModel)
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState)
}
composable<Route.Logs> {
LogsScreen()
}
composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>()
val config = appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
OptionsScreen(config, appUiState, viewModel)
}
}
composable<Route.Lock> {
PinLockScreen(viewModel)
}
composable<Route.Scanner> {
ScannerScreen(viewModel)
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
composable<Route.SplitTunnel> {
SplitTunnelScreen()
}
composable<Route.TunnelAutoTunnel> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
TunnelAutoTunnelScreen(it, appUiState.appSettings, viewModel)
}
}
}
composable<Route.Lock> {
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
}
@@ -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
}
}
@@ -44,9 +44,9 @@ 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) {
@@ -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"
@@ -3,12 +3,11 @@ 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 +19,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,17 +46,13 @@ class ServiceManager @Inject constructor(
}.onFailure { Timber.e(it) }
}
fun hasVpnPermission(): Boolean {
return VpnService.prepare(context) == null
}
suspend fun startAutoTunnel() {
autoTunnelMutex.withLock {
fun startAutoTunnel() {
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()
@@ -72,45 +62,47 @@ class ServiceManager @Inject constructor(
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, !WireGuardAutoTunnel.isForeground())
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)
fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.start(tunnelConf)
}.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 stopTunnelForegroundService() {
applicationScope.launch(ioDispatcher) {
if (!backgroundService.isCompleted) return@launch
runCatching {
val service = backgroundService.await()
service.stop()
backgroundService = CompletableDeferred()
}.onFailure {
Timber.e(it)
}
}
}
@@ -127,4 +119,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,24 +169,6 @@ class TunnelForegroundService : LifecycleService() {
)
}
private fun createTunnelsNotification(): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = "${getString(R.string.tunnel_running)} - ${getString(R.string.multiple)}",
actions = listOf(
notificationManager.createNotificationAction(NotificationAction.TUNNEL_OFF, 0),
),
)
}
private fun onCreateNotification(): Notification {
return notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = getString(R.string.tunnel_starting),
)
}
// TODO add notification handling and optional log reading for restart on handshake failures
companion object {
const val STATS_DELAY = 1_000L
const val PING_START_DELAY = 30_000L
@@ -14,8 +14,8 @@ import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotificati
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.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(
@@ -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()
}
}
}
@@ -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)
}
}
}
@@ -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
saveTunnelActiveState(tunnelConf, true)
startTunnelInner(tunnelConf)
}.onFailure { exception ->
Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace")
stopTunnel(tunnelConf)
handleBackendThrowable(exception)
}.onSuccess {
Timber.i("Tunnel ${tunnelConf.id} started successfully")
}
}
}
private suspend fun startTunnelInner(tunnelConf: TunnelConf) {
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)
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
saveTunnelActiveState(tunnelConf, false)
stopBackend(tunnel)
removeActiveTunnel(tunnel)
// use latest tunnel
handleServiceChangesOnStop()
}
}
private fun handleServiceChangesOnStop() {
if (activeTuns.value.isEmpty() && !isBounce.get()) return serviceManager.stopTunnelForegroundService()
val nextActive = activeTuns.value.keys.firstOrNull()
if (nextActive != null) {
Timber.d("Next active tunnel: ${nextActive.id}")
serviceManager.updateTunnelForegroundServiceNotification(nextActive)
}
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> {
@@ -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)
}
}
@@ -32,28 +32,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 +72,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 })
@@ -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
}
}
@@ -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
@@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@Composable
fun <T> DropdownSelector(
@@ -40,7 +41,7 @@ fun <T> DropdownSelector(
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
}
DropdownMenu(
modifier = modifier.height(250.dp),
modifier = modifier.height(250.dp.scaledHeight()),
scrollState = rememberScrollState(),
containerColor = MaterialTheme.colorScheme.surface,
expanded = isExpanded,
@@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
class NestedScrollListener(val onUp: () -> Unit, val onDown: () -> Unit) : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) onDown()
if (available.y > 1) onUp()
return Offset.Zero
}
}
@@ -0,0 +1,82 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun SearchBar(onQuery: (queryString: String) -> Unit) {
// Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) }
if (query.isEmpty()) {
showClearIcon = false
} else if (query.isNotEmpty()) {
showClearIcon = true
}
TextField(
value = query,
onValueChange = { onQueryChanged ->
// If user makes changes to text, immediately updated it.
query = onQueryChanged
onQuery(onQueryChanged)
},
leadingIcon = {
val icon = Icons.Rounded.Search
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
},
trailingIcon = {
if (showClearIcon) {
IconButton(onClick = { query = "" }) {
val icon = Icons.Rounded.Clear
Icon(
imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name,
)
}
}
},
maxLines = 1,
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier =
Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.label
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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()
@@ -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,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,31 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun AdvancedSettingsItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Settings,
title = {
Text(
stringResource(R.string.advanced_settings),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton { navController.navigate(Route.AutoTunnelAdvanced) }
},
onClick = { navController.navigate(Route.AutoTunnelAdvanced) },
)
}
@@ -1,98 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AirplanemodeActive
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<SelectionItem> {
return listOf(
SelectionItem(
leadingIcon = Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
)
},
description = {
val cellularActive = remember(uiState.networkStatus) { uiState.networkStatus?.cellularConnected ?: false }
Text(
text = if (cellularActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
),
SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
)
},
description = {
val ethernetActive = remember(uiState.networkStatus) { uiState.networkStatus?.ethernetConnected ?: false }
Text(
text = if (ethernetActive) stringResource(R.string.active) else stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
),
SelectionItem(
leadingIcon = Icons.Outlined.AirplanemodeActive,
title = {
Text(
stringResource(R.string.stop_on_no_internet),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.stop_on_internet_loss),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
),
)
}
@@ -1,196 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelingItems(
uiState: AppUiState,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
isWifiNameReadable: () -> Boolean,
): List<SelectionItem> {
val context = LocalContext.current
val baseItems = listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Wifi,
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = !uiState.appSettings.isAlwaysOnVpnEnabled,
checked = uiState.appSettings.isTunnelOnWifiEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
)
},
description = {
val wifiName by remember(uiState.networkStatus) {
derivedStateOf {
(uiState.networkStatus as? NetworkStatus.Connected)
?.takeIf { it.wifiConnected }
?.wifiSsid
}
}
Text(
text = wifiName?.let { stringResource(R.string.wifi_name_template, it) } ?: stringResource(R.string.inactive),
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi) },
),
SelectionItem(
leadingIcon = Icons.Outlined.Code,
title = {
Text(
stringResource(R.string.wifi_name_via_shell),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.use_root_shell_for_wifi),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWifiNameByShellEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleRootShellWifi) },
),
)
return if (uiState.appSettings.isTunnelOnWifiEnabled) {
baseItems + listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Filter1,
title = {
Text(
stringResource(R.string.use_wildcards),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
LearnMoreLinkLabel({ context.openWebUrl(it) }, stringResource(R.string.docs_wildcards))
},
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isWildcardsEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards) },
),
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
.padding(vertical = 6.dp),
) {
Text(
stringResource(R.string.trusted_wifi_names),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
uiState.appSettings.trustedNetworkSSIDs,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
if (uiState.appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
}
},
onValueChange = onTextChange,
supporting = { if (uiState.appSettings.isWildcardsEnabled) WildcardsLabel() },
)
},
),
SelectionItem(
leadingIcon = Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_off),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
enabled = uiState.appSettings.isVpnKillSwitchEnabled,
checked = uiState.appSettings.isDisableKillSwitchOnTrustedEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted) },
),
)
} else {
baseItems
}
}
@@ -4,61 +4,83 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FabPosition
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.Modifier
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 com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
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.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AddTunnelFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
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.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MainScreen(appUiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) {
fun MainScreen(appUiState: AppUiState, viewModel: AppViewModel) {
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 startAutoTunnel = withVpnPermission<Unit> { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
val startTunnel = withVpnPermission<TunnelConf> { viewModel.handleEvent(AppEvent.StartTunnel(it)) }
val autoTunnelToggleBattery = withIgnoreBatteryOpt(appUiState.generalState.isBatteryOptimizationDisableShown) {
if (!appUiState.generalState.isBatteryOptimizationDisableShown) viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
if (appUiState.appSettings.isKernelEnabled) viewModel.handleEvent(AppEvent.ToggleAutoTunnel) else startAutoTunnel.invoke(Unit)
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
onNoFileExplorer = { viewModel.handleEvent(AppEvent.ShowMessage(StringValue.StringResource(R.string.error_no_file_explorer))) },
onNoFileExplorer = { snackbar.showMessage(context.getString(R.string.error_no_file_explorer)) },
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
)
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.camera_permission_required)),
)
return@rememberLauncherForActivityResult
}
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
navController.navigate(Route.Scanner)
}
if (showDeleteTunnelAlertDialog && appViewState.selectedTunnel != null) {
val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
}
if (showDeleteTunnelAlertDialog && selectedTunnel != null) {
InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = {
appViewState.selectedTunnel.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
selectedTunnel?.let { viewModel.handleEvent(AppEvent.DeleteTunnel(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 +88,66 @@ 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 },
)
Scaffold(
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
if (!isRunningOnTv) {
AddTunnelFab(
isVisible = isFabVisible,
onClick = { showBottomSheet = true },
)
}
},
topBar = {
if (isRunningOnTv) {
AddTunnelFab(
isVisible = isFabVisible,
isTv = true,
onClick = { showBottomSheet = true },
)
}
},
) { padding ->
TunnelImportSheet(
showBottomSheet,
onDismiss = { showBottomSheet = false },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } },
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) },
onUrlClick = { showUrlImportDialog = true },
)
if (showUrlImportDialog) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false
if (showUrlImportDialog) {
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false
},
)
}
TunnelList(
appUiState = appUiState,
activeTunnels = appUiState.activeTunnels,
selectedTunnel = selectedTunnel,
onTunnelSelected = { selectedTunnel = it },
onDeleteTunnel = {
selectedTunnel = it
showDeleteTunnelAlertDialog = true
},
onToggleAutoTunnel = { autoTunnelToggleBattery.invoke() },
onToggleTunnel = { tunnel, checked ->
if (checked) startTunnel(tunnel) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
},
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = { viewModel.handleEvent(AppEvent.CopyTunnel(it)) },
nestedScrollConnection,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(top = 24.dp.scaledHeight()),
)
}
TunnelList(
appUiState = appUiState,
activeTunnels = appUiState.activeTunnels,
selectedTunnel = appViewState.selectedTunnel,
onSetSelectedTunnel = { viewModel.handleEvent(AppEvent.SetSelectedTunnel(it)) },
onDeleteTunnel = {
viewModel.handleEvent(AppEvent.SetSelectedTunnel(it))
showDeleteTunnelAlertDialog = true
},
onToggleTunnel = { tunnel, checked ->
if (checked) viewModel.handleEvent(AppEvent.StartTunnel(tunnel)) else viewModel.handleEvent(AppEvent.StopTunnel(tunnel))
},
onExpandStats = { viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded) },
onCopyTunnel = {
viewModel.handleEvent(AppEvent.CopyTunnel(it))
viewModel.handleEvent(AppEvent.SetSelectedTunnel(null))
},
modifier = Modifier
.fillMaxSize()
.padding(top = 12.dp, bottom = 24.dp).padding(horizontal = 12.dp),
viewModel = viewModel,
)
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.pin
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -8,6 +8,7 @@ import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
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
@@ -18,6 +19,7 @@ import xyz.teamgravity.pin_lock_compose.PinLock
fun PinLockScreen(viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val snackbar = SnackbarController.current
PinLock(
title = { pinExists ->
Text(
@@ -47,12 +49,15 @@ 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)))
snackbar.showMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
viewModel.handleEvent(AppEvent.TogglePinLock)
},
)
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.scanner
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.app.Activity
import androidx.compose.runtime.Composable
@@ -0,0 +1,175 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
buildList {
addAll(
listOf(
SelectionItem(
Icons.Outlined.PhoneAndroid,
title = {
Text(
stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isMobileDataTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isEthernetTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
),
),
)
add(
SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp.scaledHeight()),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(4f, false)
.fillMaxWidth(),
) {
val icon = Icons.Outlined.Security
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp.scaledWidth())
.padding(vertical = 6.dp.scaledHeight()),
) {
Text(
stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
}
}
}
},
description = {
TrustedNetworkTextBox(
tunnelConf.tunnelNetworks,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = { viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf)) },
onValueChange = { currentText = it },
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
),
)
},
)
}
}
}
@@ -0,0 +1,247 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Star,
title = {
Text(
stringResource(R.string.primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isPrimaryTunnel,
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.Bolt,
title = {
Text(
stringResource(R.string.auto_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.tunnel_specific_settings),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Edit,
title = {
Text(
stringResource(R.string.edit_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.Config(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.Config(id = tunnelConf.id)) }
},
),
SelectionItem(
Icons.Outlined.Dns,
title = {
Text(
stringResource(R.string.server_ipv4),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
stringResource(R.string.prefer_ipv4),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
tunnelConf.isIpv4Preferred,
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
),
SelectionItem(
Icons.AutoMirrored.Outlined.CallSplit,
title = {
Text(
stringResource(R.string.splt_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.SplitTunnel(id = tunnelConf.id))
},
trailing = {
ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) }
},
),
),
)
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
enabled = !appUiState.activeTunnels.isUp(tunnelConf),
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
),
)
if (tunnelConf.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConf.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { error -> !error.isNullOrBlank() && !error.isValidIpv4orIpv6Address() },
onSubmit = { ip ->
viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip))
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } == true
}
SubmitConfigurationTextBox(
tunnelConf.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = { interval ->
viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval))
},
)
SubmitConfigurationTextBox(
tunnelConf.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
)
},
),
)
}
},
)
}
}
}
@@ -1,51 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.EthernetTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
currentText = ""
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = buildList {
add(MobileDataTunnelItem(tunnelConf, viewModel))
add(EthernetTunnelItem(tunnelConf, viewModel))
add(WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) { currentText = it })
},
)
}
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun EthernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
text = stringResource(R.string.ethernet_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.set_ethernet_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isEthernetTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.PhoneAndroid,
title = {
Text(
text = stringResource(R.string.mobile_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.mobile_data_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isMobileDataTunnel,
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
}
@@ -1,76 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun WifiTunnelItem(
tunnelConf: TunnelConf,
appSettings: AppSettings,
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
): SelectionItem {
return SelectionItem(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Icon(
imageVector = Icons.Outlined.Security,
contentDescription = stringResource(R.string.use_tunnel_on_wifi_name),
modifier = Modifier.size(iconSize),
)
Text(
text = stringResource(R.string.use_tunnel_on_wifi_name),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.padding(start = 16.dp),
)
}
},
description = {
TrustedNetworkTextBox(
trustedNetworks = tunnelConf.tunnelNetworks,
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = {
viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf))
onTextChange("") // Reset the text field after saving
},
onValueChange = onTextChange,
supporting = {
if (appSettings.isWildcardsEnabled) {
WildcardsLabel()
}
},
)
},
)
}
@@ -0,0 +1,39 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
@Composable
fun AddTunnelFab(isVisible: Boolean = true, isTv: Boolean = false, onClick: () -> Unit) {
if (isTv) {
TopNavBar(
showBack = false,
title = stringResource(R.string.app_name),
trailing = {
IconButton(onClick = onClick) {
Icon(Icons.Outlined.Add, stringResource(R.string.add_tunnel))
}
},
)
} else {
ScrollDismissFab(
icon = {
Icon(
Icons.Filled.Add,
stringResource(R.string.add_tunnel),
tint = MaterialTheme.colorScheme.onPrimary,
)
},
isVisible = isVisible,
onClick = onClick,
)
}
}
@@ -0,0 +1,57 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@Composable
fun AutoTunnelRowItem(appUiState: AppUiState, onToggle: () -> Unit) {
val context = LocalContext.current
ExpandingRowListItem(
leading = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier =
Modifier
.size(16.dp).scale(1.5f),
tint =
if (!appUiState.autoTunnelActive) {
Color.Gray
} else {
SilverTree
},
)
},
text = stringResource(R.string.auto_tunneling),
trailing = {
ScaledSwitch(
appUiState.appSettings.isAutoTunnelEnabled,
onClick = {
onToggle()
},
)
},
onClick = {
if (context.isRunningOnTv()) {
onToggle()
}
},
isExpanded = false,
)
}
@@ -13,7 +13,6 @@ import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.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()
},
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -11,6 +12,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -19,7 +22,6 @@ 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.*
@@ -29,13 +31,14 @@ fun TunnelList(
appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>,
selectedTunnel: TunnelConf?,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onTunnelSelected: (TunnelConf) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit,
onToggleAutoTunnel: () -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit,
nestedScrollConnection: NestedScrollConnection,
modifier: Modifier = Modifier,
viewModel: AppViewModel,
) {
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
@@ -49,7 +52,9 @@ fun TunnelList(
modifier = modifier
.pointerInput(Unit) {
if (appUiState.tunnels.isEmpty()) return@pointerInput
}.overscroll(ScrollableDefaults.overscrollEffect()),
}
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
@@ -59,21 +64,24 @@ fun TunnelList(
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
} else {
item {
AutoTunnelRowItem(appUiState, onToggleAutoTunnel)
}
}
items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
isActive = tunnelState.status.isUpOrStarting(),
isActive = tunnel.isActive,
expanded = appUiState.generalState.isTunnelStatsExpanded,
isSelected = selectedTunnel?.id == tunnel.id,
tunnel = tunnel,
tunnelState = tunnelState,
onSetSelectedTunnel = { onSetSelectedTunnel(it) },
onHold = { onTunnelSelected(tunnel) },
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,47 +42,46 @@ fun TunnelRowItem(
isSelected: Boolean,
tunnel: TunnelConf,
tunnelState: TunnelState,
onSetSelectedTunnel: (TunnelConf?) -> Unit,
onHold: () -> Unit,
onClick: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
onSwitchClick: (Boolean) -> Unit,
viewModel: AppViewModel,
) {
val context = LocalContext.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val snackbar = SnackbarController.current
val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val (leadingIcon, 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)
val leadingIcon = when {
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
else -> Icons.Rounded.Circle
}
ExpandingRowListItem(
leading = {
Icon(
leadingIcon,
stringResource(R.string.status),
leadingIcon.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()
} else {
onSetSelectedTunnel(tunnel)
onHold()
itemFocusRequester.requestFocus()
}
},
@@ -94,116 +89,45 @@ fun TunnelRowItem(
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
trailing = {
if (isSelected && !isTv) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onSetSelectedTunnel(null)
navController.navigate(Route.TunnelOptions(tunnel.id))
},
) {
Icon(
Icons.Rounded.Settings,
stringResource(id = R.string.settings),
modifier = Modifier.size(24.dp),
)
Row {
IconButton(onClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) }) {
Icon(Icons.Rounded.Settings, "Settings")
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
onCopy()
onSetSelectedTunnel(null)
},
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
IconButton(modifier = Modifier.focusable(), onClick = onCopy) {
Icon(Icons.Rounded.CopyAll, "Copy")
}
IconButton(
modifier = Modifier.weight(1f),
enabled = !isActive,
onClick = { onDelete() },
) {
Icon(
Icons.Rounded.Delete,
stringResource(R.string.delete_tunnel),
modifier = Modifier.size(24.dp),
)
IconButton(modifier = Modifier.focusable(), enabled = !isActive, onClick = onDelete) {
Icon(Icons.Rounded.Delete, "Delete")
}
}
} 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),
)
Row {
IconButton(onClick = {
onHold()
navController.navigate(Route.TunnelOptions(tunnel.id))
}) {
Icon(Icons.Rounded.Settings, "Settings")
}
IconButton(
modifier = Modifier.weight(1f),
onClick = {
if (isActive) {
onClick()
} else {
viewModel.handleEvent(
AppEvent.ShowMessage(StringValue.StringResource(R.string.turn_on_tunnel)),
)
}
},
) {
Icon(
Icons.Rounded.Info,
stringResource(R.string.info),
modifier = Modifier.size(24.dp),
)
IconButton(onClick = {
if (isActive) onClick() else snackbar.showMessage(context.getString(R.string.turn_on_tunnel))
}) {
Icon(Icons.Rounded.Info, "Info")
}
IconButton(
modifier = Modifier.weight(1f),
onClick = onCopy,
) {
Icon(
Icons.Rounded.CopyAll,
stringResource(R.string.copy),
modifier = Modifier.size(24.dp),
)
IconButton(onClick = onCopy) {
Icon(Icons.Rounded.CopyAll, "Copy")
}
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),
)
IconButton(onClick = {
if (isActive) {
snackbar.showMessage(context.getString(R.string.turn_off_tunnel))
} else {
onHold()
onDelete()
}
}) {
Icon(Icons.Rounded.Delete, "Delete")
}
ScaledSwitch(
modifier = Modifier
.focusRequester(itemFocusRequester)
.weight(1f),
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
@@ -4,34 +4,44 @@ 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()
@@ -49,30 +59,13 @@ fun ConfigScreen(tunnelConf: TunnelConf?, appViewModel: AppViewModel, viewModel:
}
}
LaunchedEffect(Unit) {
// set callback for navbar to invoke save
appViewModel.handleEvent(
AppEvent.SetScreenAction {
keyboardController?.hide()
viewModel.save(tunnelConf)
},
)
}
LaunchedEffect(tunnelConf) {
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 +78,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))
}
},
)
}
}
@@ -1,38 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Bolt,
title = {
Text(
text = stringResource(R.string.auto_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.tunnel_specific_settings),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) }
},
onClick = { navController.navigate(Route.TunnelAutoTunnel(id = tunnelConf.id)) },
)
}
@@ -1,60 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PingConfigItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
title = {},
description = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SubmitConfigurationTextBox(
value = tunnelConf.pingIp,
label = stringResource(R.string.set_custom_ping_ip),
hint = stringResource(R.string.default_ping_ip),
isErrorValue = { it?.isNotBlank() == true && !it.isValidIpv4orIpv6Address() },
onSubmit = { ip -> viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip)) },
)
SubmitConfigurationTextBox(
value = tunnelConf.pingInterval?.let { (it / 1000).toString() },
label = stringResource(R.string.set_custom_ping_internal),
hint = "(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = { it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false },
onSubmit = { interval -> viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval)) },
)
SubmitConfigurationTextBox(
value = tunnelConf.pingCooldown?.let { (it / 1000).toString() },
label = stringResource(R.string.set_custom_ping_cooldown),
hint = "(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = { it?.toLongOrNull()?.let { value -> value >= Long.MAX_VALUE / 1000 } ?: false },
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
)
}
},
)
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.NetworkPing,
title = {
Text(
text = stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PrimaryTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Star,
title = {
Text(
text = stringResource(R.string.primary_tunnel),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.set_primary_tunnel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isPrimaryTunnel,
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
}
@@ -1,40 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ServerIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Dns,
title = {
Text(
text = stringResource(R.string.server_ipv4),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
description = {
Text(
text = stringResource(R.string.prefer_ipv4),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConf.isIpv4Preferred,
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
}
@@ -1,32 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.AutoMirrored.Outlined.CallSplit,
title = {
Text(
text = stringResource(R.string.splt_tunneling),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) }
},
onClick = { navController.navigate(Route.SplitTunnel(id = tunnelConf.id)) },
)
}
@@ -0,0 +1,90 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun AppearanceScreen() {
val navController = LocalNavController.current
val context = LocalContext.current
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.appearance))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Translate,
title = { Text(stringResource(R.string.language), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Language) },
trailing = {
ForwardButton { navController.navigate(Route.Language) }
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Notifications,
title = { Text(stringResource(R.string.notifications), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = {
context.launchNotificationSettings()
},
trailing = {
ForwardButton { context.launchNotificationSettings() }
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.Contrast,
title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { navController.navigate(Route.Display) },
trailing = {
ForwardButton { navController.navigate(Route.Display) }
},
),
),
)
}
}
}
@@ -0,0 +1,64 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) {
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.display_theme))
},
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize()
.padding(it)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
IconSurfaceButton(
title = stringResource(R.string.automatic),
onClick = {
viewModel.handleEvent(AppEvent.SetTheme(Theme.AUTOMATIC))
},
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
)
IconSurfaceButton(
title = stringResource(R.string.light),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.LIGHT)) },
selected = appUiState.generalState.theme == Theme.LIGHT,
)
IconSurfaceButton(
title = stringResource(R.string.dark),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DARK)) },
selected = appUiState.generalState.theme == Theme.DARK,
)
IconSurfaceButton(
title = stringResource(R.string.dynamic),
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DYNAMIC)) },
selected = appUiState.generalState.theme == Theme.DYNAMIC,
)
}
}
}
@@ -0,0 +1,129 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Lan
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val toggleVpnSwitch = withVpnPermission<Boolean> { viewModel.handleEvent(AppEvent.ToggleVpnKillSwitch) }
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.kill_switch))
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
if (!context.isRunningOnTv()) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.AdminPanelSettings,
title = {
Text(
stringResource(R.string.native_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { context.launchVpnSettings() },
trailing = {
ForwardButton { context.launchVpnSettings() }
},
),
),
)
}
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.VpnKey,
title = {
Text(
stringResource(R.string.vpn_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
toggleVpnSwitch.invoke(true)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isVpnKillSwitchEnabled,
onClick = {
toggleVpnSwitch.invoke(true)
},
)
},
),
)
if (uiState.appSettings.isVpnKillSwitchEnabled) {
add(
SelectionItem(
Icons.Outlined.Lan,
title = {
Text(
stringResource(R.string.allow_lan_traffic),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) },
description = {
Text(
stringResource(R.string.bypass_lan_for_kill_switch),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isLanOnKillSwitchEnabled,
onClick = {
viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch)
},
)
},
),
)
}
},
)
}
}
}
@@ -0,0 +1,96 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.SelectedLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import java.text.Collator
import java.util.*
@Composable
fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val collator = Collator.getInstance(Locale.getDefault())
val locales = LocaleUtil.supportedLocales.map {
val tag = it.replace("_", "-")
Locale.forLanguageTag(tag)
}
val sortedLocales =
remember(locales) {
locales.sortedWith(compareBy(collator) { it.getDisplayName(it) }).toList()
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.language))
},
) { padding ->
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize().padding(padding)
.padding(horizontal = 24.dp.scaledWidth()),
) {
item {
Box(modifier = Modifier.padding(top = 24.dp.scaledHeight())) {
SelectionItemButton(
buttonText = stringResource(R.string.automatic),
onClick = {
viewModel.handleEvent(AppEvent.SetLocale(LocaleUtil.OPTION_PHONE_LANGUAGE))
},
trailing = {
with(appUiState.generalState.locale) {
if (this == LocaleUtil.OPTION_PHONE_LANGUAGE || this == null) {
SelectedLabel()
}
}
},
ripple = false,
)
}
}
items(sortedLocales, key = { it }) { locale ->
SelectionItemButton(
buttonText = locale.getDisplayLanguage(locale).replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } +
if (locale.toLanguageTag().contains("-")) {
" (${locale.getDisplayCountry(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }})"
} else {
""
},
onClick = {
viewModel.handleEvent(AppEvent.SetLocale(locale.toLanguageTag()))
},
trailing = {
if (locale.toLanguageTag() == appUiState.generalState.locale) {
SelectedLabel()
}
},
ripple = false,
)
}
}
}
}
@@ -0,0 +1,106 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.rounded.PermScanWifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
LaunchedEffect(Unit, appUiState) {
if (appUiState.generalState.isLocationDisclosureShown) navController.goFromRoot(Route.AutoTunnel)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier.fillMaxSize().systemBarsPadding().padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
val icon = Icons.Rounded.PermScanWifi
Icon(
icon,
contentDescription = icon.name,
modifier = Modifier
.padding(30.dp.scaledHeight())
.size(128.dp.scaledHeight()),
)
Text(
stringResource(R.string.prominent_background_location_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
Text(
stringResource(R.string.prominent_background_location_message),
style = MaterialTheme.typography.bodyLarge,
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.LocationOn,
title = {
Text(
stringResource(R.string.launch_app_settings),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
},
trailing = {
ForwardButton {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
}
},
),
),
)
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
title = { Text(stringResource(R.string.skip), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
trailing = {
ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
},
),
),
)
}
}
@@ -1,88 +1,375 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewQuilt
import androidx.compose.material.icons.filled.AppShortcut
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Pin
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.VpnLock
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AlwaysOnVpnItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppearanceItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ExportTunnelsBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.PinLockItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(uiState: AppUiState, appViewState: AppViewState, viewModel: AppViewModel) {
fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val focusManager = LocalFocusManager.current
val snackbar = SnackbarController.current
val isRunningOnTv = remember { context.isRunningOnTv() }
val interactionSource = remember { MutableInteractionSource() }
if (appViewState.showBottomSheet) {
ExportTunnelsBottomSheet(viewModel, isRunningOnTv)
val interactionSource = remember { MutableInteractionSource() }
var showAuthPrompt by remember { mutableStateOf(false) }
var showExportSheet by remember { mutableStateOf(false) }
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
showExportSheet = true
},
onError = { _ ->
showAuthPrompt = false
snackbar.showMessage(
context.getString(R.string.error_authentication_failed),
)
},
onFailure = {
showAuthPrompt = false
snackbar.showMessage(
context.getString(R.string.error_authorization_failed),
)
},
)
}
if (showExportSheet) {
ModalBottomSheet(onDismissRequest = { showExportSheet = false }) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.AMNEZIA))
}
.padding(10.dp),
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(id = R.string.export_amnezia),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.export_amnezia),
modifier = Modifier.padding(10.dp),
)
}
HorizontalDivider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.WG))
}
.padding(10.dp),
) {
Icon(
Icons.Filled.FolderZip,
contentDescription = stringResource(id = R.string.export_wireguard),
modifier = Modifier.padding(10.dp),
)
Text(
stringResource(id = R.string.export_wireguard),
modifier = Modifier.padding(10.dp),
)
}
}
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
modifier =
Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.padding(top = 24.dp)
.padding(bottom = 40.dp)
.padding(horizontal = 24.dp)
.fillMaxSize().systemBarsPadding().imePadding()
.padding(top = 24.dp.scaledHeight())
.padding(bottom = 40.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth())
.then(
if (!isRunningOnTv) {
Modifier.clickable(
indication = null,
interactionSource = interactionSource,
onClick = { focusManager.clearFocus() },
)
) {
focusManager.clearFocus()
}
} else {
Modifier
},
),
) {
val onAutoTunnelClick = {
if (!uiState.generalState.isLocationDisclosureShown) {
navController.navigate(Route.LocationDisclosure)
} else {
navController.navigate(Route.AutoTunnel)
}
}
SurfaceSelectionGroupButton(
items = buildList {
add(AppShortcutsItem(uiState, viewModel))
if (!isRunningOnTv) add(AlwaysOnVpnItem(uiState, viewModel))
add(KillSwitchItem())
add(RestartAtBootItem(uiState, viewModel))
},
listOf(
SelectionItem(
Icons.Outlined.Bolt,
title = { Text(stringResource(R.string.auto_tunneling), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
description = {
Text(
stringResource(R.string.on_demand_rules),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
onClick = {
onAutoTunnelClick()
},
trailing = {
ForwardButton(Modifier.focusable()) { onAutoTunnelClick() }
},
),
),
)
SurfaceSelectionGroupButton(
items = buildList {
add(AppearanceItem())
add(LocalLoggingItem(uiState, viewModel))
if (uiState.generalState.isLocalLogsEnabled) add(ReadLogsItem())
add(PinLockItem(uiState, viewModel))
buildList {
add(
SelectionItem(
Icons.Filled.AppShortcut,
{
ScaledSwitch(
uiState.appSettings.isShortcutsEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
)
},
title = {
Text(
stringResource(R.string.enabled_app_shortcuts),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
),
)
if (!isRunningOnTv) {
add(
SelectionItem(
Icons.Outlined.VpnLock,
{
ScaledSwitch(
enabled = !(
(
uiState.appSettings.isTunnelOnWifiEnabled ||
uiState.appSettings.isTunnelOnEthernetEnabled ||
uiState.appSettings.isTunnelOnMobileDataEnabled
) &&
uiState.appSettings.isAutoTunnelEnabled
),
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
checked = uiState.appSettings.isAlwaysOnVpnEnabled,
)
},
title = {
Text(
stringResource(R.string.always_on_vpn_support),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
),
)
}
add(
SelectionItem(
Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_options),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
navController.navigate(Route.KillSwitch)
},
trailing = {
ForwardButton { navController.navigate(Route.KillSwitch) }
},
),
)
add(
SelectionItem(
Icons.Outlined.Restore,
{
ScaledSwitch(
uiState.appSettings.isRestoreOnBootEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
)
},
title = {
Text(
stringResource(R.string.restart_at_boot),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
),
)
},
)
fun onPinLockToggle() {
if (uiState.generalState.isPinLockEnabled) {
viewModel.handleEvent(AppEvent.TogglePinLock)
} else {
PinManager.initialize(context)
navController.navigate(Route.Lock)
}
}
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.AutoMirrored.Outlined.ViewQuilt,
title = { Text(stringResource(R.string.appearance), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = {
navController.navigate(Route.Appearance)
},
trailing = {
ForwardButton { navController.navigate(Route.Appearance) }
},
),
SelectionItem(
Icons.Outlined.Pin,
title = {
Text(
stringResource(R.string.enable_app_lock),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
uiState.generalState.isPinLockEnabled,
onClick = {
onPinLockToggle()
},
)
},
onClick = {
onPinLockToggle()
},
),
),
)
if (!isRunningOnTv) {
SurfaceSelectionGroupButton(
items = listOf(KernelModeItem(uiState, viewModel)),
listOf(
SelectionItem(
Icons.Outlined.Code,
title = { Text(stringResource(R.string.kernel), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface)) },
description = {
Text(
stringResource(R.string.use_kernel),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isKernelEnabled,
onClick = { viewModel.handleEvent(AppEvent.ToggleKernelMode) },
enabled = !(
uiState.appSettings.isAutoTunnelEnabled ||
uiState.appSettings.isAlwaysOnVpnEnabled ||
uiState.activeTunnels.isNotEmpty()
),
)
},
onClick = {
viewModel.handleEvent(AppEvent.ToggleKernelMode)
},
),
),
)
}
if (!isRunningOnTv) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.FolderZip,
title = {
Text(
stringResource(R.string.export_configs),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
if (uiState.tunnels.isEmpty()) return@SelectionItem context.showToast(R.string.tunnel_required)
showAuthPrompt = true
},
),
),
)
}
}
@@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.DisplayThemeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.LanguageItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components.NotificationsItem
@Composable
fun AppearanceScreen() {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
modifier = Modifier
.fillMaxSize()
.padding(top = 24.dp)
.padding(horizontal = 24.dp),
) {
SurfaceSelectionGroupButton(
items = listOf(LanguageItem()),
)
SurfaceSelectionGroupButton(
items = listOf(NotificationsItem()),
)
SurfaceSelectionGroupButton(
items = listOf(DisplayThemeItem()),
)
}
}
@@ -1,29 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun DisplayThemeItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Contrast,
title = {
Text(
text = stringResource(R.string.display_theme),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { navController.navigate(Route.Display) } },
onClick = { navController.navigate(Route.Display) },
)
}
@@ -1,29 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
@Composable
fun LanguageItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Translate,
title = {
Text(
text = stringResource(R.string.language),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { navController.navigate(Route.Language) } },
onClick = { navController.navigate(Route.Language) },
)
}

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