refactor: state management (#656)

This commit is contained in:
Zane Schepke
2025-04-01 22:18:38 -04:00
committed by GitHub
parent e63733286c
commit ca47127bff
64 changed files with 1122 additions and 1440 deletions
@@ -12,9 +12,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,7 +39,6 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route
@@ -50,15 +46,15 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ScannerScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.AppearanceScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.DisplayScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.KillSwitchScreen
@@ -66,11 +62,12 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.advanced.AdvancedScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject
@@ -85,9 +82,6 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var tunnelManager: TunnelManager
@Inject
lateinit var shortcutManager: ShortcutManager
@Inject
lateinit var networkMonitor: NetworkMonitor
@@ -107,28 +101,41 @@ class MainActivity : AppCompatActivity() {
installSplashScreen().apply {
setKeepOnScreenCondition {
!viewModel.isAppReady.value
!viewModel.appState.value.isAppReady
}
}
setContent {
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val configurationChange by viewModel.configurationChange.collectAsStateWithLifecycle()
val navController = rememberNavController()
val appState by viewModel.appState.collectAsStateWithLifecycle()
LaunchedEffect(configurationChange) {
if (configurationChange) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
if (!appState.isAppReady) {
Box(modifier = Modifier.fillMaxSize())
return@setContent
}
with(appUiState.appSettings) {
LaunchedEffect(isShortcutsEnabled) {
if (!isShortcutsEnabled) return@LaunchedEffect shortcutManager.removeShortcuts()
shortcutManager.addShortcuts()
val navController = rememberNavController()
val snackbarController = SnackbarController.current
with(appState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
Intent(this@MainActivity, MainActivity::class.java).also {
startActivity(it)
exitProcess(0)
}
}
}
LaunchedEffect(errorMessage) {
errorMessage?.let {
snackbarController.showMessage(it.asString(this@MainActivity))
}
}
LaunchedEffect(popBackStack) {
if (popBackStack) {
navController.popBackStack()
viewModel.handleEvent(AppEvent.BackStackPopped)
}
}
}
@@ -175,28 +182,19 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.Companion.fillMaxSize().padding(padding)) {
NavHost(
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (appUiState.generalState.isPinLockEnabled) Route.Lock else Route.Main),
) {
composable<Route.Main> {
MainScreen(
uiState = appUiState,
)
MainScreen(appUiState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(
appViewModel = viewModel,
uiState = appUiState,
)
SettingsScreen(appUiState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(viewModel, appUiState)
LocationDisclosureScreen(appUiState, viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(
appUiState.appSettings,
)
AutoTunnelScreen(appUiState.appSettings, viewModel)
}
composable<Route.Appearance> {
AppearanceScreen()
@@ -205,10 +203,10 @@ class MainActivity : AppCompatActivity() {
LanguageScreen(appUiState, viewModel)
}
composable<Route.Display> {
DisplayScreen(appUiState)
DisplayScreen(appUiState, viewModel)
}
composable<Route.Support> {
SupportScreen(appUiState)
SupportScreen(appUiState, viewModel)
}
composable<Route.AutoTunnelAdvanced> {
AdvancedScreen(appUiState)
@@ -218,21 +216,20 @@ class MainActivity : AppCompatActivity() {
}
composable<Route.Config> { backStack ->
val args = backStack.toRoute<Route.Config>()
val config =
appUiState.tunnels.firstOrNull { it.id == args.id }
val config = appUiState.tunnels.firstOrNull { it.id == args.id }
ConfigScreen(config)
}
composable<Route.TunnelOptions> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let { config ->
OptionsScreen(config, appUiState)
OptionsScreen(config, appUiState, viewModel)
}
}
composable<Route.Lock> {
PinLockScreen(viewModel)
}
composable<Route.Scanner> {
ScannerScreen()
ScannerScreen(viewModel)
}
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
@@ -243,7 +240,7 @@ class MainActivity : AppCompatActivity() {
composable<Route.TunnelAutoTunnel> { backStack ->
val args = backStack.toRoute<Route.TunnelOptions>()
appUiState.tunnels.firstOrNull { it.id == args.id }?.let {
TunnelAutoTunnelScreen(it, appUiState.appSettings)
TunnelAutoTunnelScreen(it, appUiState.appSettings, viewModel)
}
}
}
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -4,9 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.hilt.android.AndroidEntryPoint
@@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel.core.broadcast
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -51,7 +51,7 @@ class RestartReceiver : BroadcastReceiver() {
if (settings.isRestoreOnBootEnabled) {
if (settings.isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) {
Timber.d("Starting auto-tunnel on boot/update")
serviceManager.startAutoTunnel(true)
serviceManager.startAutoTunnel()
} else {
Timber.d("Restoring previous tunnel state")
tunnelManager.restorePreviousState()
@@ -4,8 +4,8 @@ import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification.NotificationChannels
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.util.StringValue
interface NotificationManager {
@@ -12,9 +12,9 @@ import android.graphics.Color
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.broadcast.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.MainActivity
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager.Companion.EXTRA_ID
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.util.StringValue
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.core.service
import android.app.Service
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
@@ -45,7 +46,7 @@ class ServiceManager @Inject constructor(
}.onFailure { Timber.e(it) }
}
fun startAutoTunnel(background: Boolean) {
fun startAutoTunnel() {
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
appDataRepository.settings.save(settings.copy(isAutoTunnelEnabled = true))
@@ -55,7 +56,7 @@ class ServiceManager @Inject constructor(
}
runCatching {
autoTunnelService = CompletableDeferred()
startService(AutoTunnelService::class.java, background)
startService(AutoTunnelService::class.java, !WireGuardAutoTunnel.isForeground())
_autoTunnelActive.update { true }
}.onFailure {
Timber.e(it)
@@ -70,7 +71,7 @@ class ServiceManager @Inject constructor(
if (backgroundService.isCompleted) return@launch
runCatching {
backgroundService = CompletableDeferred()
startService(TunnelForegroundService::class.java, true)
startService(TunnelForegroundService::class.java, !WireGuardAutoTunnel.isForeground())
val service = withTimeoutOrNull(SERVICE_START_TIMEOUT) { backgroundService.await() }
?: throw IllegalStateException("Background service start timed out")
service.start(tunnelConf)
@@ -105,9 +106,9 @@ class ServiceManager @Inject constructor(
}
}
fun toggleAutoTunnel(background: Boolean) {
fun toggleAutoTunnel() {
applicationScope.launch(ioDispatcher) {
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel(background)
if (_autoTunnelActive.value) stopAutoTunnel() else startAutoTunnel()
}
}
@@ -8,8 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -63,7 +63,7 @@ class AutoTunnelControlTile : TileService(), LifecycleOwner {
serviceManager.stopAutoTunnel()
setInactive()
} else {
serviceManager.startAutoTunnel(true)
serviceManager.startAutoTunnel()
setActive()
}
}
@@ -2,12 +2,12 @@ package com.zaneschepke.wireguardautotunnel.core.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -53,7 +53,7 @@ class ShortcutsActivity : ComponentActivity() {
}
AutoTunnelService::class.java.simpleName, LEGACY_AUTO_TUNNEL_SERVICE_NAME -> {
when (intent.action) {
Action.START.name -> serviceManager.startAutoTunnel(true)
Action.START.name -> serviceManager.startAutoTunnel()
Action.STOP.name -> serviceManager.stopAutoTunnel()
}
}
@@ -136,6 +136,7 @@ abstract class BaseTunnel(
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")
@@ -151,7 +152,6 @@ abstract class BaseTunnel(
mutex.withLock {
configureTunnelCallbacks(tunnelConf)
startBackend(tunnelConf)
saveTunnelActiveState(tunnelConf, true)
if (!isBounce.get()) serviceManager.startTunnelForegroundService(tunnelConf)
}
}
@@ -175,10 +175,10 @@ abstract class BaseTunnel(
private suspend fun stopTunnelInner(tunnelConf: TunnelConf) {
mutex.withLock {
val tunnel = findActiveTunnel(tunnelConf.id) ?: return
saveTunnelActiveState(tunnelConf, false)
stopBackend(tunnel)
removeActiveTunnel(tunnel)
// use latest tunnel
saveTunnelActiveState(tunnelConf, false)
handleServiceChangesOnStop()
}
}
@@ -52,7 +52,7 @@ class ServiceWorker @AssistedInject constructor(
override suspend fun doWork(): Result = withContext(ioDispatcher) {
Timber.i("Service worker started")
with(appDataRepository.settings.get()) {
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel(true)
if (isAutoTunnelEnabled && !serviceManager.autoTunnelActive.value) return@with serviceManager.startAutoTunnel()
if (tunnelManager.activeTunnels.value.isEmpty()) tunnelManager.restorePreviousState()
}
Result.success()
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters {
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import javax.inject.Inject
class AppDataRoomRepository
@@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
@@ -30,6 +30,12 @@ class RoomTunnelRepository(
}
}
override suspend fun saveAll(tunnelConfs: List<TunnelConf>) {
withContext(ioDispatcher) {
tunnelConfigDao.saveAll(tunnelConfs.map(TunnelConfig::from))
}
}
override suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?) {
withContext(ioDispatcher) {
tunnelConfigDao.resetPrimaryTunnel()
@@ -4,17 +4,17 @@ import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import com.zaneschepke.wireguardautotunnel.data.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.DataStoreManager
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import dagger.Module
import dagger.Provides
@@ -3,6 +3,9 @@ package com.zaneschepke.wireguardautotunnel.domain.entity
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.defaultName
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.withContext
@@ -106,6 +109,21 @@ data class TunnelConf(
return updatedConf.wgQuick != wgQuick || updatedConf.amQuick != amQuick || updatedConf.name != name
}
fun generateUniqueName(tunnelNames: List<String>): String {
var tunnelName = this.tunName
var num = 1
while (tunnelNames.any { it == tunnelName }) {
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
"$name($num)"
} else {
val pair = tunnelName.extractNameAndNumber()
"${pair?.first}($num)"
}
num++
}
return tunnelName
}
suspend fun isTunnelPingable(context: CoroutineContext): Boolean {
return withContext(context) {
val config = toWgConfig()
@@ -138,10 +156,10 @@ data class TunnelConf(
}
}
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String): TunnelConf {
fun tunnelConfigFromAmConfig(config: org.amnezia.awg.config.Config, name: String? = null): TunnelConf {
val amQuick = config.toAwgQuickString(true)
val wgQuick = config.toWgQuickString()
return TunnelConf(tunName = name, wgQuick = wgQuick, amQuick = amQuick)
return TunnelConf(tunName = name ?: config.defaultName(), wgQuick = wgQuick, amQuick = amQuick)
}
private const val IPV6_ALL_NETWORKS = "::/0"
@@ -11,6 +11,8 @@ interface TunnelRepository {
suspend fun save(tunnelConf: TunnelConf)
suspend fun saveAll(tunnelConfList: List<TunnelConf>)
suspend fun updatePrimaryTunnel(tunnelConf: TunnelConf?)
suspend fun updateMobileDataTunnel(tunnelConf: TunnelConf?)
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
@@ -2,23 +2,9 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,47 +13,34 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.NestedScrollListener
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AddTunnelFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelList
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.UrlImportDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.viewmodel.MainViewModel
import java.text.Collator
import java.util.Locale
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) {
fun MainScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val clipboard = LocalClipboardManager.current
@@ -80,52 +53,32 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
var showUrlImportDialog by remember { mutableStateOf(false) }
val isRunningOnTv = remember { context.isRunningOnTv() }
val activeTunnels by viewModel.tunnelManager.activeTunnels.collectAsStateWithLifecycle(emptyMap())
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(uiState.tunnels) {
uiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
val startAutoTunnel = withVpnPermission<Unit> { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
val startTunnel = withVpnPermission<TunnelConf> { viewModel.handleEvent(AppEvent.StartTunnel(it)) }
val autoTunnelToggleBattery = withIgnoreBatteryOpt(appUiState.generalState.isBatteryOptimizationDisableShown) {
if (!appUiState.generalState.isBatteryOptimizationDisableShown) viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
if (appUiState.appSettings.isKernelEnabled) viewModel.handleEvent(AppEvent.ToggleAutoTunnel) else startAutoTunnel.invoke(Unit)
}
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
val startTunnel = withVpnPermission<TunnelConf> {
viewModel.onTunnelStart(it)
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(
onNoFileExplorer = { snackbar.showMessage(context.getString(R.string.error_no_file_explorer)) },
onData = { data -> viewModel.handleEvent(AppEvent.ImportTunnelFromFile(data)) },
)
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
if (uiState.appSettings.isKernelEnabled) {
viewModel.onToggleAutoTunnel()
} else {
startAutoTunnel.invoke(Unit)
}
val requestPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
navController.navigate(Route.Scanner)
}
val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
snackbar.showMessage(
context.getString(R.string.error_no_file_explorer),
)
}, onData = { data ->
viewModel.onTunnelFileSelected(data, context)
})
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
if (!isGranted) return@rememberLauncherForActivityResult snackbar.showMessage("Camera permission required")
navController.navigate(Route.Scanner)
}
if (showDeleteTunnelAlertDialog) {
if (showDeleteTunnelAlertDialog && selectedTunnel != null) {
InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false },
onAttest = {
selectedTunnel?.let { viewModel.onDelete(it) }
selectedTunnel?.let { viewModel.handleEvent(AppEvent.DeleteTunnel(it)) }
showDeleteTunnelAlertDialog = false
selectedTunnel = null
},
@@ -135,52 +88,22 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
)
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConf) {
if (!checked) return viewModel.onTunnelStop(tunnel).let { }
if (uiState.appSettings.isKernelEnabled) viewModel.onTunnelStart(tunnel) else startTunnel(tunnel)
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
if (uiState.tunnels.isEmpty()) return@pointerInput
detectTapGestures(
onTap = {
selectedTunnel = null
},
)
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
if (!isRunningOnTv) {
ScrollDismissFab({
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, isVisible = isFabVisible, onClick = {
showBottomSheet = true
})
AddTunnelFab(
isVisible = isFabVisible,
onClick = { showBottomSheet = true },
)
}
},
topBar = {
if (isRunningOnTv) {
TopNavBar(
showBack = false,
title = stringResource(R.string.app_name),
trailing = {
IconButton(onClick = {
showBottomSheet = true
}) {
val icon = Icons.Outlined.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
)
}
},
AddTunnelFab(
isVisible = isFabVisible,
isTv = true,
onClick = { showBottomSheet = true },
)
}
},
@@ -190,16 +113,8 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
onDismiss = { showBottomSheet = false },
onFileClick = { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_TV_FILE_TYPES) },
onQrClick = { requestPermissionLauncher.launch(android.Manifest.permission.CAMERA) },
onClipboardClick = {
clipboard.getText()?.text?.let {
viewModel.onClipboardImport(it)
}
},
onManualImportClick = {
navController.navigate(
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
)
},
onClipboardClick = { clipboard.getText()?.text?.let { viewModel.handleEvent(AppEvent.ImportTunnelFromClipboard(it)) } },
onManualImportClick = { navController.navigate(Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID)) },
onUrlClick = { showUrlImportDialog = true },
)
@@ -207,55 +122,32 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
UrlImportDialog(
onDismiss = { showUrlImportDialog = false },
onConfirm = { url ->
viewModel.onUrlImport(url)
viewModel.handleEvent(AppEvent.ImportTunnelFromUrl(url))
showUrlImportDialog = false
},
)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding).padding(top = 24.dp.scaledHeight())
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (uiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
} else {
item {
AutoTunnelRowItem(uiState) {
autoTunnelToggleBattery.invoke()
}
}
}
items(
sortedTunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
tunnelState.state.isUp(),
expanded,
selectedTunnel?.id == tunnel.id,
tunnel,
tunnelState = tunnelState,
{ selectedTunnel = tunnel },
{ viewModel.onExpandedChanged(!expanded) },
onDelete = { showDeleteTunnelAlertDialog = true },
onCopy = { viewModel.onCopyTunnel(tunnel) },
onSwitchClick = { onTunnelToggle(it, tunnel) },
)
}
}
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()),
)
}
}
@@ -6,16 +6,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinLock
@Composable
fun PinLockScreen(appViewModel: AppViewModel) {
fun PinLockScreen(viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val snackbar = SnackbarController.current
@@ -57,7 +58,7 @@ fun PinLockScreen(appViewModel: AppViewModel) {
snackbar.showMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
appViewModel.onPinLockEnabled()
viewModel.handleEvent(AppEvent.TogglePinLock)
},
)
}
@@ -3,26 +3,16 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.journeyapps.barcodescanner.CompoundBarcodeView
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.viewmodel.ScannerViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
fun ScannerScreen(viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val success = viewModel.success.collectAsStateWithLifecycle(null)
LaunchedEffect(success.value) {
if (success.value != null) navController.popBackStack()
}
val barcodeView = remember {
CompoundBarcodeView(context).apply {
@@ -30,7 +20,7 @@ fun ScannerScreen(viewModel: ScannerViewModel = hiltViewModel()) {
this.setStatusText("")
this.decodeSingle { result ->
result.text?.let { barCodeOrQr ->
viewModel.onTunnelQrResult(barCodeOrQr)
viewModel.handleEvent(AppEvent.ImportTunnelFromQrCode(barCodeOrQr))
}
}
}
@@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
@@ -40,10 +39,11 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.compon
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelAutoTunnelViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tunnelAutoTunnelViewModel: TunnelAutoTunnelViewModel = hiltViewModel()) {
fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, viewModel: AppViewModel) {
var currentText by remember { mutableStateOf("") }
LaunchedEffect(tunnelConf.tunnelNetworks) {
@@ -86,10 +86,10 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
trailing = {
ScaledSwitch(
tunnelConf.isMobileDataTunnel,
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleMobileDataTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.SettingsEthernet,
@@ -108,10 +108,10 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
trailing = {
ScaledSwitch(
tunnelConf.isEthernetTunnel,
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleEthernetTunnel(tunnelConf)) },
),
),
)
@@ -155,9 +155,9 @@ fun TunnelAutoTunnelScreen(tunnelConf: TunnelConf, appSettings: AppSettings, tun
description = {
TrustedNetworkTextBox(
tunnelConf.tunnelNetworks,
onDelete = { tunnelAutoTunnelViewModel.onDeleteRunSSID(it, tunnelConf) },
onDelete = { viewModel.handleEvent(AppEvent.DeleteTunnelRunSSID(it, tunnelConf)) },
currentText = currentText,
onSave = { tunnelAutoTunnelViewModel.onSaveRunSSID(it, tunnelConf) },
onSave = { viewModel.handleEvent(AppEvent.AddTunnelRunSSID(it, tunnelConf)) },
onValueChange = { currentText = it },
supporting = {
if (appSettings.isWildcardsEnabled) {
@@ -30,30 +30,27 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelOptionsViewModel
import kotlin.text.isBlank
import kotlin.text.isNullOrBlank
import kotlin.text.toLong
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: TunnelOptionsViewModel = hiltViewModel()) {
fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") }
@@ -62,10 +59,6 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
currentText = ""
}
val onPingToggle = {
viewModel.saveTunnel(tunnelConf.copy(isPingEnabled = !tunnelConf.isPingEnabled))
}
Scaffold(
topBar = {
TopNavBar(tunnelConf.tunName)
@@ -102,10 +95,10 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
trailing = {
ScaledSwitch(
tunnelConf.isPrimaryTunnel,
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
)
},
onClick = { viewModel.onTogglePrimaryTunnel(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.TogglePrimaryTunnel(tunnelConf)) },
),
SelectionItem(
Icons.Outlined.Bolt,
@@ -160,10 +153,10 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
trailing = {
ScaledSwitch(
tunnelConf.isIpv4Preferred,
onClick = { viewModel.onToggleIpv4(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
)
},
onClick = { viewModel.onToggleIpv4(tunnelConf) },
onClick = { viewModel.handleEvent(AppEvent.ToggleIpv4Preferred(tunnelConf)) },
),
SelectionItem(
Icons.AutoMirrored.Outlined.CallSplit,
@@ -197,10 +190,10 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
ScaledSwitch(
checked = tunnelConf.isPingEnabled,
enabled = !appUiState.activeTunnels.isUp(tunnelConf),
onClick = { onPingToggle() },
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
)
},
onClick = { onPingToggle() },
onClick = { viewModel.handleEvent(AppEvent.TogglePingTunnelEnabled(tunnelConf)) },
),
)
if (tunnelConf.isPingEnabled) {
@@ -212,11 +205,9 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
tunnelConf.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
viewModel.saveTunnel(
tunnelConf.copy(pingIp = it.ifBlank { null }),
)
isErrorValue = { error -> !error.isNullOrBlank() && !error.isValidIpv4orIpv6Address() },
onSubmit = { ip ->
viewModel.handleEvent(AppEvent.SetTunnelPingIp(tunnelConf, ip))
},
)
fun isSecondsError(seconds: String?): Boolean {
@@ -231,8 +222,8 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
viewModel.onPingIntervalChange(tunnelConf, it)
onSubmit = { interval ->
viewModel.handleEvent(AppEvent.SetTunnelPingInterval(tunnelConf, interval))
},
)
SubmitConfigurationTextBox(
@@ -243,7 +234,7 @@ fun OptionsScreen(tunnelConf: TunnelConf, appUiState: AppUiState, viewModel: Tun
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = { viewModel.onPingCoolDownChange(tunnelConf, it) },
onSubmit = { cooldown -> viewModel.handleEvent(AppEvent.SetTunnelPingCooldown(tunnelConf, cooldown)) },
)
},
),
@@ -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,
)
}
}
@@ -12,9 +12,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@@ -8,8 +8,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPasteGo
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -0,0 +1,89 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.core.tunnel.getValueById
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import java.text.Collator
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TunnelList(
appUiState: AppUiState,
activeTunnels: Map<TunnelConf, TunnelState>,
selectedTunnel: TunnelConf?,
onTunnelSelected: (TunnelConf) -> Unit,
onDeleteTunnel: (TunnelConf) -> Unit,
onToggleAutoTunnel: () -> Unit,
onToggleTunnel: (TunnelConf, Boolean) -> Unit,
onExpandStats: () -> Unit,
onCopyTunnel: (TunnelConf) -> Unit,
nestedScrollConnection: NestedScrollConnection,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels = remember(appUiState.tunnels) {
appUiState.tunnels.sortedWith(compareBy(collator) { it.tunName })
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier = modifier
.pointerInput(Unit) {
if (appUiState.tunnels.isEmpty()) return@pointerInput
detectTapGestures(onTap = { onTunnelSelected(null!!) })
}
.overscroll(ScrollableDefaults.overscrollEffect())
.nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (appUiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(onClick = { context.openWebUrl(it) })
}
} else {
item {
AutoTunnelRowItem(appUiState, onToggleAutoTunnel)
}
}
items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState = activeTunnels.getValueById(tunnel.id) ?: TunnelState()
TunnelRowItem(
isActive = tunnel.isActive,
expanded = appUiState.generalState.isTunnelStatsExpanded,
isSelected = selectedTunnel?.id == tunnel.id,
tunnel = tunnel,
tunnelState = tunnelState,
onHold = { onTunnelSelected(tunnel) },
onClick = onExpandStats,
onCopy = { onCopyTunnel(tunnel) },
onDelete = { onDeleteTunnel(tunnel) },
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
)
}
}
}
@@ -46,25 +46,28 @@ fun TunnelRowItem(
onClick: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
onSwitchClick: (checked: Boolean) -> Unit,
onSwitchClick: (Boolean) -> Unit,
) {
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val context = LocalContext.current
val snackbar = SnackbarController.current
val navController = LocalNavController.current
val haptic = LocalHapticFeedback.current
val snackbar = SnackbarController.current
val itemFocusRequester = remember { FocusRequester() }
val isTv = context.isRunningOnTv()
val leadingIconColor = if (!isActive) Color.Gray else tunnelState.statistics.asColor()
val leadingIcon = when {
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
else -> Icons.Rounded.Circle
}
ExpandingRowListItem(
leading = {
val icon = when {
tunnel.isPrimaryTunnel -> Icons.Rounded.Star
tunnel.isMobileDataTunnel -> Icons.Rounded.Smartphone
tunnel.isEthernetTunnel -> Icons.Rounded.SettingsEthernet
else -> Icons.Rounded.Circle
}
Icon(
icon,
icon.name,
leadingIcon,
leadingIcon.name,
tint = leadingIconColor,
modifier = Modifier.size(16.dp),
)
@@ -75,10 +78,8 @@ fun TunnelRowItem(
onHold()
},
onClick = {
if (!context.isRunningOnTv()) {
if (isActive) {
onClick()
}
if (!isTv) {
if (isActive) onClick()
} else {
onHold()
itemFocusRequester.requestFocus()
@@ -87,108 +88,56 @@ fun TunnelRowItem(
isExpanded = expanded && isActive,
expanded = { if (isActive && expanded) TunnelStatisticsRow(tunnelState.statistics, tunnel) },
trailing = {
if (
isSelected &&
!context.isRunningOnTv()
) {
if (isSelected && !isTv) {
Row {
IconButton(
onClick = {
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
IconButton(onClick = { navController.navigate(Route.TunnelOptions(tunnel.id)) }) {
Icon(Icons.Rounded.Settings, "Settings")
}
IconButton(
modifier = Modifier.focusable(),
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
IconButton(modifier = Modifier.focusable(), onClick = onCopy) {
Icon(Icons.Rounded.CopyAll, "Copy")
}
IconButton(
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { onDelete() },
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
IconButton(modifier = Modifier.focusable(), enabled = !isActive, onClick = onDelete) {
Icon(Icons.Rounded.Delete, "Delete")
}
}
} else {
if (context.isRunningOnTv()) {
Row {
IconButton(
onClick = {
onHold()
navController.navigate(
Route.TunnelOptions(tunnel.id),
)
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
onClick = {
if (isActive) {
onClick()
} else {
snackbar.showMessage(
context.getString(R.string.turn_on_tunnel),
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { onCopy() },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (isActive) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
onHold()
onDelete()
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
} else if (isTv) {
Row {
IconButton(onClick = {
onHold()
navController.navigate(Route.TunnelOptions(tunnel.id))
}) {
Icon(Icons.Rounded.Settings, "Settings")
}
IconButton(onClick = {
if (isActive) onClick() else snackbar.showMessage(context.getString(R.string.turn_on_tunnel))
}) {
Icon(Icons.Rounded.Info, "Info")
}
IconButton(onClick = onCopy) {
Icon(Icons.Rounded.CopyAll, "Copy")
}
IconButton(onClick = {
if (isActive) {
snackbar.showMessage(context.getString(R.string.turn_off_tunnel))
} else {
onHold()
onDelete()
}
}) {
Icon(Icons.Rounded.Delete, "Delete")
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
} else {
ScaledSwitch(
modifier = Modifier.focusRequester(itemFocusRequester),
checked = isActive,
onClick = onSwitchClick,
)
}
},
)
@@ -19,11 +19,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@@ -10,18 +10,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.DisplayViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltViewModel()) {
fun DisplayScreen(appUiState: AppUiState, viewModel: AppViewModel) {
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.display_theme))
@@ -40,23 +40,23 @@ fun DisplayScreen(appUiState: AppUiState, viewModel: DisplayViewModel = hiltView
IconSurfaceButton(
title = stringResource(R.string.automatic),
onClick = {
viewModel.onThemeChange(Theme.AUTOMATIC)
viewModel.handleEvent(AppEvent.SetTheme(Theme.AUTOMATIC))
},
selected = appUiState.generalState.theme == Theme.AUTOMATIC,
)
IconSurfaceButton(
title = stringResource(R.string.light),
onClick = { viewModel.onThemeChange(Theme.LIGHT) },
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.LIGHT)) },
selected = appUiState.generalState.theme == Theme.LIGHT,
)
IconSurfaceButton(
title = stringResource(R.string.dark),
onClick = { viewModel.onThemeChange(Theme.DARK) },
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DARK)) },
selected = appUiState.generalState.theme == Theme.DARK,
)
IconSurfaceButton(
title = stringResource(R.string.dynamic),
onClick = { viewModel.onThemeChange(Theme.DYNAMIC) },
onClick = { viewModel.handleEvent(AppEvent.SetTheme(Theme.DYNAMIC)) },
selected = appUiState.generalState.theme == Theme.DYNAMIC,
)
}
@@ -18,44 +18,24 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val toggleVpnSwitch = withVpnPermission<Boolean> { appViewModel.onToggleVpnKillSwitch(it) }
fun toggleVpnKillSwitch() {
with(uiState.appSettings) {
// TODO improve this error message
if (isKernelEnabled) return SnackbarController.showMessage(StringValue.StringResource(R.string.kernel_not_supported))
if (isVpnKillSwitchEnabled) {
appViewModel.onToggleVpnKillSwitch(false)
} else {
toggleVpnSwitch.invoke(true)
}
}
}
fun toggleLanOnKillSwitch() {
with(uiState.appSettings) {
appViewModel.onToggleLanOnKillSwitch(!isLanOnKillSwitchEnabled)
}
}
val toggleVpnSwitch = withVpnPermission<Boolean> { viewModel.handleEvent(AppEvent.ToggleVpnKillSwitch) }
Scaffold(
topBar = {
@@ -102,13 +82,13 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
)
},
onClick = {
toggleVpnKillSwitch()
toggleVpnSwitch.invoke(true)
},
trailing = {
ScaledSwitch(
uiState.appSettings.isVpnKillSwitchEnabled,
onClick = {
toggleVpnKillSwitch()
toggleVpnSwitch.invoke(true)
},
)
},
@@ -124,7 +104,7 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { toggleLanOnKillSwitch() },
onClick = { viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch) },
description = {
Text(
stringResource(R.string.bypass_lan_for_kill_switch),
@@ -135,7 +115,7 @@ fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
ScaledSwitch(
uiState.appSettings.isLanOnKillSwitchEnabled,
onClick = {
toggleLanOnKillSwitch()
viewModel.handleEvent(AppEvent.ToggleLanOnKillSwitch)
},
)
},
@@ -14,19 +14,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.SelectedLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import java.text.Collator
import java.util.Locale
import java.util.*
@Composable
fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
fun LanguageScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val collator = Collator.getInstance(Locale.getDefault())
val locales = LocaleUtil.supportedLocales.map {
@@ -57,7 +58,7 @@ fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
SelectionItemButton(
buttonText = stringResource(R.string.automatic),
onClick = {
appViewModel.onLocaleChange(LocaleUtil.OPTION_PHONE_LANGUAGE)
viewModel.handleEvent(AppEvent.SetLocale(LocaleUtil.OPTION_PHONE_LANGUAGE))
},
trailing = {
with(appUiState.generalState.locale) {
@@ -80,7 +81,7 @@ fun LanguageScreen(appUiState: AppUiState, appViewModel: AppViewModel) {
""
},
onClick = {
appViewModel.onLocaleChange(locale.toLanguageTag())
viewModel.handleEvent(AppEvent.SetLocale(locale.toLanguageTag()))
},
trailing = {
if (locale.toLanguageTag() == appUiState.generalState.locale) {
@@ -21,20 +21,21 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState) {
fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
@@ -77,13 +78,13 @@ fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState)
},
onClick = {
context.launchAppSettings().also {
appViewModel.setLocationDisclosureShown()
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
},
trailing = {
ForwardButton {
context.launchAppSettings().also {
appViewModel.setLocationDisclosureShown()
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
}
},
@@ -94,9 +95,9 @@ fun LocationDisclosureScreen(appViewModel: AppViewModel, appUiState: AppUiState)
listOf(
SelectionItem(
title = { Text(stringResource(R.string.skip), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
onClick = { appViewModel.setLocationDisclosureShown() },
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
trailing = {
ForwardButton { appViewModel.setLocationDisclosureShown() }
ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
},
),
),
@@ -41,29 +41,28 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
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.SettingsViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import xyz.teamgravity.pin_lock_compose.PinManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel: AppViewModel, uiState: AppUiState) {
fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val focusManager = LocalFocusManager.current
@@ -104,7 +103,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.exportAllConfigs(context, ConfigType.AMNEZIA)
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.AMNEZIA))
}
.padding(10.dp),
) {
@@ -125,7 +124,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
.fillMaxWidth()
.clickable {
showExportSheet = false
viewModel.exportAllConfigs(context, ConfigType.WG)
viewModel.handleEvent(AppEvent.ExportTunnels(ConfigType.WG))
}
.padding(10.dp),
) {
@@ -200,7 +199,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
{
ScaledSwitch(
uiState.appSettings.isShortcutsEnabled,
onClick = { appViewModel.onToggleShortcutsEnabled() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
)
},
title = {
@@ -209,7 +208,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { appViewModel.onToggleShortcutsEnabled() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAppShortcuts) },
),
)
if (!isRunningOnTv) {
@@ -226,7 +225,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
) &&
uiState.appSettings.isAutoTunnelEnabled
),
onClick = { appViewModel.onToggleAlwaysOnVPN() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
checked = uiState.appSettings.isAlwaysOnVpnEnabled,
)
},
@@ -236,7 +235,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { appViewModel.onToggleAlwaysOnVPN() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAlwaysOn) },
),
)
}
@@ -264,7 +263,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
{
ScaledSwitch(
uiState.appSettings.isRestoreOnBootEnabled,
onClick = { appViewModel.onToggleRestartAtBoot() },
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
)
},
title = {
@@ -273,12 +272,21 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { appViewModel.onToggleRestartAtBoot() },
onClick = { viewModel.handleEvent(AppEvent.ToggleRestartAtBoot) },
),
)
},
)
fun onPinLockToggle() {
if (uiState.generalState.isPinLockEnabled) {
viewModel.handleEvent(AppEvent.TogglePinLock)
} else {
PinManager.initialize(context)
navController.navigate(Route.Lock)
}
}
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
@@ -303,22 +311,12 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
ScaledSwitch(
uiState.generalState.isPinLockEnabled,
onClick = {
if (uiState.generalState.isPinLockEnabled) {
appViewModel.onPinLockDisabled()
} else {
PinManager.initialize(context)
navController.navigate(Route.Lock)
}
onPinLockToggle()
},
)
},
onClick = {
if (uiState.generalState.isPinLockEnabled) {
appViewModel.onPinLockDisabled()
} else {
PinManager.initialize(context)
navController.navigate(Route.Lock)
}
onPinLockToggle()
},
),
),
@@ -339,7 +337,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
trailing = {
ScaledSwitch(
uiState.appSettings.isKernelEnabled,
onClick = { appViewModel.onToggleKernelMode() },
onClick = { viewModel.handleEvent(AppEvent.ToggleKernelMode) },
enabled = !(
uiState.appSettings.isAutoTunnelEnabled ||
uiState.appSettings.isAlwaysOnVpnEnabled ||
@@ -348,7 +346,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
)
},
onClick = {
appViewModel.onToggleKernelMode()
viewModel.handleEvent(AppEvent.ToggleKernelMode)
},
),
),
@@ -4,7 +4,6 @@ import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -38,13 +37,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
@@ -53,7 +52,6 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LearnMoreLinkLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@@ -62,11 +60,12 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel = hiltViewModel()) {
fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
@@ -159,12 +158,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
enabled = !appSettings.isAlwaysOnVpnEnabled,
checked = appSettings.isTunnelOnWifiEnabled,
onClick = {
viewModel.onToggleTunnelOnWifi()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi)
},
)
},
onClick = {
viewModel.onToggleTunnelOnWifi()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnWifi)
},
),
SelectionItem(
@@ -185,12 +184,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
ScaledSwitch(
checked = appSettings.isWifiNameByShellEnabled,
onClick = {
viewModel.onRootShellWifiToggle()
viewModel.handleEvent(AppEvent.ToggleRootShellWifi)
},
)
},
onClick = {
viewModel.onRootShellWifiToggle()
viewModel.handleEvent(AppEvent.ToggleRootShellWifi)
},
),
),
@@ -213,12 +212,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
ScaledSwitch(
checked = appSettings.isWildcardsEnabled,
onClick = {
viewModel.onToggleWildcards()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards)
},
)
},
onClick = {
viewModel.onToggleWildcards()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelWildcards)
},
),
SelectionItem(
@@ -258,10 +257,10 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
description = {
TrustedNetworkTextBox(
appSettings.trustedNetworkSSIDs,
onDelete = { viewModel.onDeleteTrustedSSID(it) },
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
if (appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) viewModel.onSaveTrustedSSID(ssid)
if (appSettings.isWifiNameByShellEnabled || isWifiNameReadable()) viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
},
onValueChange = { currentText = it },
supporting = {
@@ -287,12 +286,12 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
enabled = appSettings.isVpnKillSwitchEnabled,
checked = appSettings.isDisableKillSwitchOnTrustedEnabled,
onClick = {
viewModel.onToggleStopKillSwitchOnTrusted()
viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted)
},
)
},
onClick = {
viewModel.onToggleStopKillSwitchOnTrusted()
viewModel.handleEvent(AppEvent.ToggleStopKillSwitchOnTrusted)
},
),
),
@@ -314,11 +313,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
ScaledSwitch(
enabled = !appSettings.isAlwaysOnVpnEnabled,
checked = appSettings.isTunnelOnMobileDataEnabled,
onClick = { viewModel.onToggleTunnelOnMobileData() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
)
},
onClick = {
viewModel.onToggleTunnelOnMobileData()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular)
},
),
SelectionItem(
@@ -333,11 +332,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
ScaledSwitch(
enabled = !appSettings.isAlwaysOnVpnEnabled,
checked = appSettings.isTunnelOnEthernetEnabled,
onClick = { viewModel.onToggleTunnelOnEthernet() },
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
)
},
onClick = {
viewModel.onToggleTunnelOnEthernet()
viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet)
},
),
SelectionItem(
@@ -357,11 +356,11 @@ fun AutoTunnelScreen(appSettings: AppSettings, viewModel: AutoTunnelViewModel =
trailing = {
ScaledSwitch(
checked = appSettings.isStopOnNoInternetEnabled,
onClick = { viewModel.onToggleStopOnNoInternet() },
onClick = { viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet) },
)
},
onClick = {
viewModel.onToggleStopOnNoInternet()
viewModel.handleEvent(AppEvent.ToggleStopTunnelOnNoInternet)
},
),
),
@@ -13,13 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.VersionLabel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.ContactSupportOptions
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.GeneralSupportOptions
import com.zaneschepke.wireguardautotunnel.ui.screens.support.components.VersionLabel
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@@ -28,9 +27,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun SupportScreen(appUiState: AppUiState) {
val viewModel: AppViewModel = hiltViewModel()
fun SupportScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val isTv = context.isRunningOnTv()
@@ -9,10 +9,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@@ -8,11 +8,11 @@ import androidx.compose.material.icons.outlined.ViewHeadline
import androidx.compose.runtime.Composable
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@@ -30,24 +30,26 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
import com.zaneschepke.wireguardautotunnel.viewmodel.LogsViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
val logs = viewModel.logs
fun LogsScreen() {
val appViewModel = hiltViewModel<AppViewModel>()
val logs by appViewModel.logs.collectAsStateWithLifecycle()
val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val lazyColumnListState = rememberLazyListState()
@@ -91,7 +93,7 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
floatingActionButton = {
FloatingActionButton(
onClick = {
viewModel.shareLogs(context)
appViewModel.handleEvent(AppEvent.ExportLogs)
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary,
@@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import com.zaneschepke.wireguardautotunnel.util.StringValue
data class AppState(
val isConfigChanged: Boolean = false,
val errorMessage: StringValue? = null,
val popBackStack: Boolean = false,
val isAppReady: Boolean = false,
)
@@ -11,4 +11,6 @@ data class AppUiState(
val activeTunnels: Map<TunnelConf, TunnelState> = emptyMap(),
val generalState: GeneralState = GeneralState(),
val autoTunnelActive: Boolean = false,
val appConfigurationChange: Boolean = false,
val isAppLoaded: Boolean = false,
)
@@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.ui.state
import com.wireguard.config.Interface
import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim
import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList
import kotlin.ranges.contains
data class InterfaceProxy(
val privateKey: String = "",
@@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.util
object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val LOG_BUFFER_SIZE = 3_000L
const val LOG_BUFFER_SIZE = 10_000L
const val MANUAL_TUNNEL_CONFIG_ID = 0
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
@@ -1,14 +1,25 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import androidx.core.content.FileProvider
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
import timber.log.Timber
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class FileUtils(
@@ -16,60 +27,137 @@ class FileUtils(
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> {
return withContext(ioDispatcher) {
tunnels.map { config ->
val file = File(context.cacheDir, "${config.tunName}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
suspend fun createWgFiles(tunnels: List<TunnelConf>): List<File> = withContext(ioDispatcher) {
tunnels.map { config ->
val file = File(context.cacheDir, "${config.tunName}-wg.conf")
file.outputStream().use {
it.write(config.wgQuick.toByteArray())
}
file
}
}
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> {
return withContext(ioDispatcher) {
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.tunName}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
suspend fun createAmFiles(tunnels: List<TunnelConf>): List<File> = withContext(ioDispatcher) {
tunnels.filter { it.amQuick != TunnelConfig.AM_QUICK_DEFAULT }.map { config ->
val file = File(context.cacheDir, "${config.tunName}-am.conf")
file.outputStream().use {
it.write(config.amQuick.toByteArray())
}
file
}
}
suspend fun zipAll(zipFile: File, files: List<File>) {
withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file ->
val zipFileName = (
file.parentFile?.let { parent ->
file.absolutePath.removePrefix(parent.absolutePath)
} ?: file.absolutePath
).removePrefix("/")
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use {
it.copyTo(zos)
}
suspend fun zipAll(zipFile: File, files: List<File>) = withContext(ioDispatcher) {
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
files.forEach { file ->
val zipFileName = (
file.parentFile?.let { parent ->
file.absolutePath.removePrefix(parent.absolutePath)
} ?: file.absolutePath
).removePrefix("/")
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use {
it.copyTo(zos)
}
}
}
}
}
suspend fun createNewShareFile(name: String): File {
return withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path}/$name")
if (file.exists()) file.delete()
file.createNewFile()
file
suspend fun createNewShareFile(name: String): File = withContext(ioDispatcher) {
val sharePath = File(context.filesDir, "external_files")
if (sharePath.exists()) sharePath.delete()
sharePath.mkdir()
val file = File("${sharePath.path}/$name")
if (file.exists()) file.delete()
file.createNewFile()
file
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) return null
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
val move = cursor.moveToFirst()
if (!move) return null
val index = getDisplayNameColumnIndex(cursor)
if (index == null) return index
return cursor.getString(index)
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(uri: Uri): String {
return getFileNameByCursor(uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String? {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
Timber.e(e)
null
}
}
private fun getFileNameByCursor(uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it)
}
}
suspend fun buildTunnelsFromUri(uri: Uri): List<TunnelConf> = withContext(ioDispatcher) {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
val fileName = getFileName(uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> {
context.getInputStreamFromUri(uri)?.use { inputStream ->
val name = getNameFromFileName(fileName)
val amConf = Config.parse(inputStream)
listOf(
TunnelConf(
tunName = name,
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
),
)
} ?: throw FileReadException
}
Constants.ZIP_FILE_EXTENSION -> {
ZipInputStream(context.getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.map { entry ->
val name = getNameFromFileName(entry.name)
val amConf = Config.parse(zip.bufferedReader())
TunnelConf(
tunName = name,
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
)
}.toList()
}
}
else -> throw InvalidFileExtensionException
}
}
fun shareFile(shareFile: File) {
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri)
}
}
@@ -21,6 +21,7 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.Constants
import java.io.InputStream
private const val BASELINE_HEIGHT = 2201
private const val BASELINE_WIDTH = 1080
@@ -143,6 +144,10 @@ fun Context.launchVpnSettings(): Result<Unit> {
}
}
fun Context.getInputStreamFromUri(uri: Uri): InputStream? {
return this.applicationContext.contentResolver.openInputStream(uri)
}
fun Context.launchLocationServicesSettings(): Result<Unit> {
return kotlin.runCatching {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
@@ -1,9 +1,9 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ReceiveChannel
@@ -13,7 +13,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.selects.whileSelect
import timber.log.Timber
@@ -81,13 +80,6 @@ fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
}
}
fun Job.cancelWithMessage(message: String) {
kotlin.runCatching {
cancel()
Timber.i(message)
}
}
suspend fun <T> StateFlow<T?>.withData(callback: suspend (T) -> Unit) {
return this.filterNotNull().first().let { callback(it) }
suspend fun <R> StateFlow<AppUiState>.withFirstState(block: suspend (AppUiState) -> R): R {
return block(first { it.isAppLoaded })
}
@@ -83,6 +83,15 @@ fun Config.toWgQuickString(): String {
return lines.joinToString(System.lineSeparator())
}
fun Config.defaultName(): String {
return try {
this.peers[0].endpoint.get().host
} catch (e: Exception) {
Timber.Forest.e(e)
NumberUtils.generateRandomTunnelName()
}
}
fun Backend.BackendState.asBackendState(): BackendState {
return BackendState.valueOf(this.name)
}
@@ -1,39 +1,58 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.AppShell
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.state.AppState
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
import com.zaneschepke.wireguardautotunnel.util.extensions.withFirstState
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import java.net.URL
import java.time.Instant
import javax.inject.Inject
import javax.inject.Provider
@@ -41,19 +60,27 @@ import javax.inject.Provider
class AppViewModel
@Inject
constructor(
appDataRepository: AppDataRepository,
val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
@AppShell private val rootShell: Provider<RootShell>,
private val tunnelManager: TunnelManager,
private val serviceManager: ServiceManager,
private val logReader: LogReader,
) : BaseViewModel(appDataRepository) {
private val fileUtils: FileUtils,
private val shortcutManager: ShortcutManager,
) : ViewModel() {
private val _isAppReady = MutableStateFlow(false)
val isAppReady = _isAppReady.asStateFlow()
private val tunnelMutex = Mutex()
private val settingsMutex = Mutex()
private val loggerMutex = Mutex()
private val tunControlMutex = Mutex()
private val _configurationChange = MutableStateFlow(false)
val configurationChange = _configurationChange.asStateFlow()
private val _appState = MutableStateFlow(AppState())
val appState = _appState.asStateFlow()
private val _logs = MutableStateFlow<List<LogMessage>>(emptyList())
val logs: StateFlow<List<LogMessage>> = _logs.asStateFlow()
val uiState =
combine(
@@ -69,6 +96,7 @@ constructor(
activeTunnels,
generalState,
autoTunnel,
isAppLoaded = true,
)
}.stateIn(
viewModelScope + ioDispatcher,
@@ -77,186 +105,472 @@ constructor(
)
init {
viewModelScope.launch {
initPin()
handleKillSwitchChange()
initServices()
launch {
initTunnels()
viewModelScope.launch(ioDispatcher) {
uiState.withFirstState { realState ->
Timber.d("Real state: $realState")
initPin(realState.generalState.isPinLockEnabled)
handleKillSwitchChange(realState.appSettings)
initServicesFromSavedState(realState)
_appState.update { it.copy(isAppReady = true) }
}
appReadyCheck()
uiState.filter { it.generalState.isLocalLogsEnabled }.first()
collectLogs()
}
}
fun handleEvent(event: AppEvent) = viewModelScope.launch {
with(uiState.value) {
fun handleEvent(event: AppEvent) = viewModelScope.launch(ioDispatcher) {
uiState.withFirstState { state ->
Timber.d("handleEvent: $event")
when (event) {
AppEvent.ToggleLocalLogging -> {
val enabled = generalState.isLocalLogsEnabled
appDataRepository.appState.setLocalLogsEnabled(!enabled)
if (!enabled) logReader.start() else logReader.stop()
}
is AppEvent.SetDebounceDelay -> saveAppSettings(appSettings.copy(debounceDelaySeconds = event.delay))
AppEvent.ToggleLocalLogging -> onToggleLocalLogging(state.generalState.isLocalLogsEnabled)
is AppEvent.SetDebounceDelay -> onSetDebounceDelay(state.appSettings, event.delay)
is AppEvent.CopyTunnel -> onCopyTunnel(event.tunnel, state.tunnels)
is AppEvent.DeleteTunnel -> onDeleteTunnel(event.tunnel, state)
is AppEvent.ImportTunnelFromClipboard -> onClipboardImport(event.text, state.tunnels)
is AppEvent.ImportTunnelFromFile -> onImportTunnelFromFile(event.data, state.tunnels)
is AppEvent.ImportTunnelFromUrl -> onImportTunnelFromUrl(event.url, state.tunnels)
is AppEvent.ImportTunnelFromQrCode -> onImportTunnelFromQr(event.qrCode, state.tunnels)
AppEvent.SetBatteryOptimizeDisableShown -> setBatteryOptimizeDisableShown()
is AppEvent.StartTunnel -> onStartTunnel(event.tunnel)
is AppEvent.StopTunnel -> onStopTunnel(event.tunnel)
AppEvent.ToggleAutoTunnel -> onToggleAutoTunnel()
AppEvent.ToggleTunnelStatsExpanded -> onToggleExpandTunnelStats(state.generalState.isTunnelStatsExpanded)
AppEvent.ToggleAlwaysOn -> onToggleAlwaysOnVPN(state.appSettings)
AppEvent.TogglePinLock -> onPinLockToggled(state.generalState.isPinLockEnabled)
AppEvent.SetLocationDisclosureShown -> setLocationDisclosureShown()
is AppEvent.SetLocale -> onLocaleChange(event.localeTag)
AppEvent.ToggleRestartAtBoot -> onToggleRestartAtBoot(state.appSettings)
AppEvent.ToggleVpnKillSwitch -> onToggleVpnKillSwitch(state.appSettings)
AppEvent.ToggleLanOnKillSwitch -> onToggleLanOnKillSwitch(state.appSettings)
AppEvent.ToggleAppShortcuts -> onToggleAppShortcuts(state.appSettings)
AppEvent.ToggleKernelMode -> onToggleKernelMode(state.appSettings)
is AppEvent.SetTheme -> onThemeChange(event.theme)
is AppEvent.ToggleIpv4Preferred -> onToggleIpv4(event.tunnel)
is AppEvent.TogglePrimaryTunnel -> onTogglePrimaryTunnel(event.tunnel)
is AppEvent.SetTunnelPingCooldown -> onPingCoolDownChange(event.tunnel, event.pingCooldown)
is AppEvent.SetTunnelPingInterval -> onPingIntervalChange(event.tunnel, event.pingInterval)
is AppEvent.AddTunnelRunSSID -> onAddTunnelRunSSID(event.ssid, event.tunnel, state.tunnels)
is AppEvent.DeleteTunnelRunSSID -> onRemoveTunnelRunSSID(event.ssid, event.tunnel)
is AppEvent.ToggleEthernetTunnel -> onToggleEthernetTunnel(event.tunnel)
is AppEvent.ToggleMobileDataTunnel -> onToggleMobileDataTunnel(event.tunnel)
AppEvent.ToggleAutoTunnelOnCellular -> onToggleAutoTunnelOnCellular(state.appSettings)
AppEvent.ToggleAutoTunnelOnWifi -> onToggleAutoTunnelOnWifi(state.appSettings)
is AppEvent.DeleteTrustedSSID -> onDeleteTrustedSSID(event.ssid, state.appSettings)
AppEvent.ToggleAutoTunnelWildcards -> onToggleAutoTunnelWildcards(state.appSettings)
AppEvent.ToggleRootShellWifi -> onToggleRootShellWifi(state.appSettings)
is AppEvent.SaveTrustedSSID -> onSaveTrustedSSID(event.ssid, state.appSettings)
AppEvent.ToggleAutoTunnelOnEthernet -> onToggleTunnelOnEthernet(state.appSettings)
AppEvent.ToggleStopKillSwitchOnTrusted -> onToggleStopKillSwitchOnTrusted(state.appSettings)
AppEvent.ToggleStopTunnelOnNoInternet -> onToggleStopOnNoInternet(state.appSettings)
is AppEvent.ExportTunnels -> onExportTunnels(event.configType, state.tunnels)
AppEvent.ExportLogs -> onExportLogs()
AppEvent.ErrorShown -> onErrorShown()
AppEvent.BackStackPopped -> _appState.update { it.copy(popBackStack = false) }
is AppEvent.TogglePingTunnelEnabled -> onTogglePingTunnel(event.tunnel)
is AppEvent.SetTunnelPingIp -> onTunnelPingIpChange(event.tunnelConf, event.ip)
}
}
}
private suspend fun appReadyCheck() {
val tunnelCount = appDataRepository.tunnels.count()
uiState.first { it.tunnels.count() == tunnelCount }
_isAppReady.emit(true)
private fun collectLogs() {
viewModelScope.launch(ioDispatcher) {
logReader.bufferedLogs
.runningFold(emptyList<LogMessage>()) { accumulator, log ->
val updated = accumulator + log
if (updated.size > Constants.LOG_BUFFER_SIZE) updated.takeLast(Constants.LOG_BUFFER_SIZE.toInt()) else updated
}
.collect { _logs.value = it }
}
}
private suspend fun initTunnels() {
tunnels.withData { tunnels ->
tunnels.filter { it.isActive }.forEach {
private suspend fun onTunnelPingIpChange(tunnelConf: TunnelConf, ip: String) = saveTunnel(
tunnelConf.copy(pingIp = ip),
)
private suspend fun onTogglePingTunnel(tunnel: TunnelConf) = saveTunnel(
tunnel.copy(isPingEnabled = !tunnel.isPingEnabled),
)
private suspend fun onToggleLocalLogging(currentlyEnabled: Boolean) {
loggerMutex.withLock {
val newEnabled = !currentlyEnabled
appDataRepository.appState.setLocalLogsEnabled(newEnabled)
withContext(mainDispatcher) {
if (newEnabled) logReader.start() else logReader.stop()
}
if (!newEnabled) _logs.value = emptyList()
}
}
private suspend fun onSetDebounceDelay(appSettings: AppSettings, delay: Int) = saveSettings(
appSettings.copy(debounceDelaySeconds = delay),
)
private suspend fun onCopyTunnel(tunnel: TunnelConf, existingTunnels: List<TunnelConf>) = saveTunnel(
TunnelConf(
tunName = tunnel.generateUniqueName(existingTunnels.map { it.tunName }),
wgQuick = tunnel.wgQuick,
amQuick = tunnel.amQuick,
),
)
private suspend fun onDeleteTunnel(tunnel: TunnelConf, state: AppUiState) {
if (state.tunnels.size == 1 || tunnel.isPrimaryTunnel) {
serviceManager.stopAutoTunnel()
}
appDataRepository.tunnels.delete(tunnel)
}
private suspend fun onStartTunnel(tunnel: TunnelConf) {
tunControlMutex.withLock {
tunnelManager.startTunnel(tunnel)
}
}
private suspend fun onStopTunnel(tunnel: TunnelConf) {
tunControlMutex.withLock {
tunnelManager.stopTunnel(tunnel)
}
}
private suspend fun onToggleAutoTunnel() {
tunControlMutex.withLock {
serviceManager.toggleAutoTunnel()
}
}
private suspend fun onToggleExpandTunnelStats(currentlyEnabled: Boolean) {
appDataRepository.appState.setTunnelStatsExpanded(!currentlyEnabled)
}
private fun onErrorShown() {
_appState.update { it.copy(errorMessage = null) }
}
private fun onError(message: StringValue) {
_appState.update { it.copy(errorMessage = message) }
}
private fun popBackStack() {
_appState.update { it.copy(popBackStack = true) }
}
private suspend fun onImportTunnelFromFile(uri: Uri, tunnels: List<TunnelConf>) {
runCatching {
val tunnelConfigs = fileUtils.buildTunnelsFromUri(uri)
val existingNames = tunnels.map { it.tunName }.toMutableList()
val uniqueTunnelConfigs = tunnelConfigs.map { config ->
val uniqueName = config.generateUniqueName(existingNames)
existingNames.add(uniqueName)
config.copy(tunName = uniqueName)
}
appDataRepository.tunnels.saveAll(uniqueTunnelConfigs)
}.onFailure {
// TODO handle exceptions, show message to UI
Timber.e(it)
}
}
private suspend fun onClipboardImport(config: String, tunnels: List<TunnelConf>) {
runCatching {
val amConfig = TunnelConf.configFromAmQuick(config)
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig)
saveTunnel(tunnelConf.copy(tunName = tunnelConf.generateUniqueName(tunnels.map { it.tunName })))
}.onFailure {
Timber.e(it)
onError(StringValue.StringResource(R.string.error_file_format))
}
}
private suspend fun onImportTunnelFromUrl(urlString: String, tunnels: List<TunnelConf>) {
runCatching {
val url = URL(urlString)
val fileName = urlString.substringAfterLast("/")
if (!fileName.endsWith(Constants.CONF_FILE_EXTENSION)) {
throw InvalidFileExtensionException
}
url.openStream().use { stream ->
val amConfig = Config.parse(stream)
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig)
saveTunnel(tunnelConf.copy(tunName = tunnelConf.generateUniqueName(tunnels.map { it.tunName })))
}
}.onFailure {
Timber.e(it)
val message = when (it) {
is InvalidFileExtensionException -> StringValue.StringResource(R.string.error_file_extension)
else -> StringValue.StringResource(R.string.error_download_failed)
}
onError(message)
}
}
private suspend fun onImportTunnelFromQr(result: String, existingTunnels: List<TunnelConf>) {
onClipboardImport(result, existingTunnels)
popBackStack()
}
private suspend fun setBatteryOptimizeDisableShown() {
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
private fun initServicesFromSavedState(state: AppUiState) = viewModelScope.launch(ioDispatcher) {
tunControlMutex.withLock {
if (state.appSettings.isAutoTunnelEnabled) serviceManager.startAutoTunnel()
state.tunnels.filter { it.isActive }.forEach {
tunnelManager.startTunnel(it)
}
}
}
private suspend fun initPin() {
val isPinEnabled = appDataRepository.appState.isPinLockEnabled()
if (isPinEnabled) PinManager.initialize(WireGuardAutoTunnel.instance)
private fun initPin(enabled: Boolean) {
if (enabled) PinManager.initialize(WireGuardAutoTunnel.instance)
}
private suspend fun initServices() {
withContext(ioDispatcher) {
appSettings.withData {
if (it.isAutoTunnelEnabled) serviceManager.startAutoTunnel(false)
}
}
private suspend fun onPinLockToggled(currentlyEnabled: Boolean) {
if (currentlyEnabled) PinManager.clearPin()
appDataRepository.appState.setPinLockEnabled(!currentlyEnabled)
}
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
PinManager.clearPin()
appDataRepository.appState.setPinLockEnabled(false)
}
fun onPinLockEnabled() = viewModelScope.launch {
appDataRepository.appState.setPinLockEnabled(true)
}
fun setLocationDisclosureShown() = viewModelScope.launch {
private suspend fun setLocationDisclosureShown() {
appDataRepository.appState.setLocationDisclosureShown(true)
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch {
with(uiState.value.appSettings) {
appDataRepository.settings.save(
copy(
isAlwaysOnVpnEnabled = !isAlwaysOnVpnEnabled,
),
)
}
}
private suspend fun onToggleAlwaysOnVPN(appSettings: AppSettings) = saveSettings(
appSettings.copy(
isAlwaysOnVpnEnabled = !appSettings.isAlwaysOnVpnEnabled,
),
)
fun onLocaleChange(localeTag: String) = viewModelScope.launch {
private suspend fun onLocaleChange(localeTag: String) {
appDataRepository.appState.setLocale(localeTag)
LocaleUtil.changeLocale(localeTag)
_configurationChange.update {
true
}
_appState.update { it.copy(isConfigChanged = true) }
}
fun onToggleRestartAtBoot() = viewModelScope.launch {
with(uiState.value.appSettings) {
appDataRepository.settings.save(
copy(
isRestoreOnBootEnabled = !isRestoreOnBootEnabled,
),
)
}
private suspend fun onToggleRestartAtBoot(appSettings: AppSettings) = saveSettings(
appSettings.copy(
isRestoreOnBootEnabled = !appSettings.isRestoreOnBootEnabled,
),
)
private suspend fun onToggleVpnKillSwitch(appSettings: AppSettings) {
val enabled = !appSettings.isVpnKillSwitchEnabled
val updatedSettings = appSettings.copy(
isVpnKillSwitchEnabled = enabled,
isLanOnKillSwitchEnabled = if (enabled) appSettings.isLanOnKillSwitchEnabled else false,
)
saveSettings(updatedSettings)
handleKillSwitchChange(updatedSettings)
}
fun onToggleVpnKillSwitch(enabled: Boolean) = viewModelScope.launch {
with(uiState.value.appSettings) {
appDataRepository.settings.save(
copy(
isVpnKillSwitchEnabled = enabled,
isLanOnKillSwitchEnabled = if (enabled) isLanOnKillSwitchEnabled else false,
),
)
}
handleKillSwitchChange()
private suspend fun onToggleLanOnKillSwitch(appSettings: AppSettings) {
val updatedSettings = appSettings.copy(
isLanOnKillSwitchEnabled = !appSettings.isLanOnKillSwitchEnabled,
)
saveSettings(updatedSettings)
handleKillSwitchChange(appSettings)
}
private suspend fun handleKillSwitchChange() {
withContext(ioDispatcher) {
appSettings.withData {
if (!it.isVpnKillSwitchEnabled) return@withData tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
Timber.d("Starting kill switch")
val allowedIps = if (it.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
}
}
private suspend fun handleKillSwitchChange(appSettings: AppSettings) {
if (!appSettings.isVpnKillSwitchEnabled) return tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
Timber.d("Starting kill switch")
val allowedIps = if (appSettings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS else emptyList()
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
}
fun onToggleLanOnKillSwitch(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
appDataRepository.settings.save(
uiState.value.appSettings.copy(
isLanOnKillSwitchEnabled = enabled,
private suspend fun onToggleAppShortcuts(appSettings: AppSettings) {
val enabled = !appSettings.isShortcutsEnabled
if (enabled) shortcutManager.addShortcuts() else shortcutManager.removeShortcuts()
saveSettings(
appSettings.copy(
isShortcutsEnabled = enabled,
),
)
handleKillSwitchChange()
}
fun onToggleShortcutsEnabled() = viewModelScope.launch {
with(uiState.value.appSettings) {
appDataRepository.settings.save(
copy(
isShortcutsEnabled = !isShortcutsEnabled,
),
private suspend fun onTogglePrimaryTunnel(tunnelConf: TunnelConf) {
tunnelMutex.withLock {
appDataRepository.tunnels.updatePrimaryTunnel(
when (tunnelConf.isPrimaryTunnel) {
true -> null
false -> tunnelConf
},
)
}
}
private fun saveKernelMode(enabled: Boolean) = viewModelScope.launch {
with(uiState.value.appSettings) {
appDataRepository.settings.save(
this.copy(
isKernelEnabled = enabled,
),
private suspend fun onToggleIpv4(tunnelConf: TunnelConf) = saveTunnel(
tunnelConf.copy(
isIpv4Preferred = !tunnelConf.isIpv4Preferred,
),
)
private suspend fun onPingIntervalChange(tunnelConf: TunnelConf, interval: String) = saveTunnel(
tunnelConf.copy(pingInterval = if (interval.isBlank()) null else interval.toLong() * 1000),
)
private suspend fun onPingCoolDownChange(tunnelConf: TunnelConf, cooldown: String) = saveTunnel(
tunnelConf.copy(pingCooldown = if (cooldown.isBlank()) null else cooldown.toLong() * 1000),
)
private suspend fun onThemeChange(theme: Theme) {
appDataRepository.appState.setTheme(theme)
}
private suspend fun onToggleKernelMode(appSettings: AppSettings) {
val enabled = !appSettings.isKernelEnabled
if (enabled && !isKernelSupported()) {
onError(StringValue.StringResource(R.string.kernel_not_supported))
return
}
if (enabled && !requestRoot()) return
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
saveSettings(appSettings.copy(isKernelEnabled = enabled))
}
private suspend fun onRemoveTunnelRunSSID(ssid: String, tunnelConfig: TunnelConf) = saveTunnel(
tunnelConfig.copy(
tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(),
),
)
private suspend fun onAddTunnelRunSSID(ssid: String, tunnelConf: TunnelConf, existingTunnels: List<TunnelConf>) {
if (ssid.isBlank()) return
val trimmed = ssid.trim()
if (existingTunnels.any { it.tunnelNetworks.contains(trimmed) }) return onError(StringValue.StringResource(R.string.error_ssid_exists))
saveTunnel(
tunnelConf.copy(
tunnelNetworks = (tunnelConf.tunnelNetworks + ssid).toMutableList(),
),
)
}
private suspend fun onToggleMobileDataTunnel(tunnelConf: TunnelConf) {
tunnelMutex.withLock {
if (tunnelConf.isMobileDataTunnel) return appDataRepository.tunnels.updateMobileDataTunnel(null)
appDataRepository.tunnels.updateMobileDataTunnel(tunnelConf)
}
}
private suspend fun onToggleEthernetTunnel(tunnelConf: TunnelConf) {
tunnelMutex.withLock {
if (tunnelConf.isEthernetTunnel) return appDataRepository.tunnels.updateEthernetTunnel(null)
appDataRepository.tunnels.updateEthernetTunnel(tunnelConf)
}
}
private suspend fun onToggleAutoTunnelOnWifi(appSettings: AppSettings) = saveSettings(
appSettings.copy(
isTunnelOnWifiEnabled = !appSettings.isTunnelOnWifiEnabled,
),
)
private suspend fun onToggleAutoTunnelOnCellular(appSettings: AppSettings) = saveSettings(
appSettings.copy(
isTunnelOnMobileDataEnabled = !appSettings.isTunnelOnMobileDataEnabled,
),
)
private suspend fun onToggleAutoTunnelWildcards(appSettings: AppSettings) = saveSettings(
appSettings.copy(
isWildcardsEnabled = !appSettings.isWildcardsEnabled,
),
)
private suspend fun onDeleteTrustedSSID(ssid: String, appSettings: AppSettings) = saveSettings(
appSettings.copy(
trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
private suspend fun onToggleRootShellWifi(appSettings: AppSettings) {
if (requestRoot()) {
saveSettings(
appSettings.copy(isWifiNameByShellEnabled = !appSettings.isWifiNameByShellEnabled),
)
}
}
fun onToggleKernelMode() = viewModelScope.launch {
with(uiState.value.appSettings) {
if (!isKernelEnabled) {
requestRoot().onSuccess {
if (!isKernelSupported()) {
return@onSuccess SnackbarController.showMessage(
StringValue.StringResource(R.string.kernel_not_supported),
)
}
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
appDataRepository.settings.save(
copy(
isKernelEnabled = true,
isAmneziaEnabled = false,
),
)
}
} else {
saveKernelMode(enabled = false)
}
}
private suspend fun onToggleTunnelOnEthernet(appSettings: AppSettings) = saveSettings(
appSettings.copy(isTunnelOnEthernetEnabled = !appSettings.isTunnelOnEthernetEnabled),
)
private suspend fun onSaveTrustedSSID(ssid: String, appSettings: AppSettings) {
if (ssid.isEmpty()) return
val trimmed = ssid.trim()
if (appSettings.trustedNetworkSSIDs.contains(trimmed)) return onError(StringValue.StringResource(R.string.error_ssid_exists))
saveSettings(
appSettings.copy(
trustedNetworkSSIDs = (appSettings.trustedNetworkSSIDs + ssid).toMutableList(),
),
)
}
private suspend fun onToggleStopOnNoInternet(appSettings: AppSettings) = saveSettings(
appSettings.copy(isStopOnNoInternetEnabled = !appSettings.isStopOnNoInternetEnabled),
)
private suspend fun onToggleStopKillSwitchOnTrusted(appSettings: AppSettings) = saveSettings(
appSettings.copy(isDisableKillSwitchOnTrustedEnabled = !appSettings.isDisableKillSwitchOnTrustedEnabled),
)
private suspend fun isKernelSupported(): Boolean {
return withContext(ioDispatcher) {
WgQuickBackend.hasKernelSupport()
}
}
private suspend fun requestRoot(): Result<Unit> {
private suspend fun saveSettings(appSettings: AppSettings) = withContext(ioDispatcher) {
settingsMutex.withLock {
appDataRepository.settings.save(appSettings)
}
}
private suspend fun saveTunnel(tunnel: TunnelConf) = withContext(ioDispatcher) {
tunnelMutex.withLock {
appDataRepository.tunnels.save(tunnel)
}
}
private suspend fun onExportTunnels(configType: ConfigType, tunnels: List<TunnelConf>) {
runCatching {
val (files, shareFileName) = when (configType) {
ConfigType.AMNEZIA -> {
Pair(fileUtils.createAmFiles(tunnels), "am-export_${Instant.now().epochSecond}.zip")
}
ConfigType.WG -> {
Pair(fileUtils.createWgFiles(tunnels), "wg-export_${Instant.now().epochSecond}.zip")
}
}
val shareFile = fileUtils.createNewShareFile(shareFileName)
fileUtils.zipAll(shareFile, files)
fileUtils.shareFile(shareFile)
}.onFailure {
// TODO handle error
Timber.e(it)
}
}
private suspend fun onExportLogs() {
runCatching {
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
logReader.zipLogFiles(file.absolutePath)
fileUtils.shareFile(file)
}.onFailure {
// TODO handle error
Timber.e(it)
}
}
private suspend fun requestRoot(): Boolean {
return withContext(ioDispatcher) {
runCatching {
try {
rootShell.get().start()
SnackbarController.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure {
true
} catch (e: Exception) {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_root_denied))
false
}
}
}
@@ -1,132 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.AppShell
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Provider
@HiltViewModel
class AutoTunnelViewModel
@Inject
constructor(
appDataRepository: AppDataRepository,
@AppShell private val rootShell: Provider<RootShell>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : BaseViewModel(appDataRepository) {
fun onToggleTunnelOnWifi() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(
isTunnelOnWifiEnabled = !it.isTunnelOnWifiEnabled,
),
)
}
}
fun onToggleTunnelOnMobileData() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(
isTunnelOnMobileDataEnabled = !it.isTunnelOnMobileDataEnabled,
),
)
}
}
fun onToggleWildcards() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(
isWildcardsEnabled = !it.isWildcardsEnabled,
),
)
}
}
fun onDeleteTrustedSSID(ssid: String) = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(
trustedNetworkSSIDs = (it.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
}
fun onRootShellWifiToggle() = viewModelScope.launch {
requestRoot().onSuccess {
appSettings.withData {
saveAppSettings(
it.copy(isWifiNameByShellEnabled = !it.isWifiNameByShellEnabled),
)
}
}
}
private suspend fun requestRoot(): Result<Unit> {
return withContext(ioDispatcher) {
runCatching {
rootShell.get().start()
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.root_accepted))
}.onFailure {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_root_denied))
}
}
}
fun onToggleTunnelOnEthernet() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(isTunnelOnEthernetEnabled = !it.isTunnelOnEthernetEnabled),
)
}
}
fun onSaveTrustedSSID(ssid: String) = viewModelScope.launch {
if (ssid.isEmpty()) return@launch
val trimmed = ssid.trim()
appSettings.withData {
if (!it.trustedNetworkSSIDs.contains(trimmed)) {
saveAppSettings(
it.copy(
trustedNetworkSSIDs = (it.trustedNetworkSSIDs + ssid).toMutableList(),
),
)
} else {
SnackbarController.Companion.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
}
fun onToggleStopOnNoInternet() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(isStopOnNoInternetEnabled = !it.isStopOnNoInternetEnabled),
)
}
}
fun onToggleStopKillSwitchOnTrusted() = viewModelScope.launch {
appSettings.withData {
saveAppSettings(
it.copy(isDisableKillSwitchOnTrustedEnabled = !it.isDisableKillSwitchOnTrustedEnabled),
)
}
}
}
@@ -1,39 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
open class BaseViewModel @Inject constructor(
protected val appDataRepository: AppDataRepository,
) : ViewModel() {
val appSettings: StateFlow<AppSettings?> = appDataRepository.settings.flow.stateIn(
scope = viewModelScope,
started = SharingStarted.Companion.WhileSubscribed(5000),
initialValue = null,
)
val tunnels: StateFlow<List<TunnelConf>?> = appDataRepository.tunnels.flow.stateIn(
scope = viewModelScope,
started = SharingStarted.Companion.WhileSubscribed(5000),
initialValue = null,
)
fun saveAppSettings(appSettings: AppSettings) = viewModelScope.launch {
appDataRepository.settings.save(appSettings)
}
fun saveTunnel(tunnelConf: TunnelConf) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConf)
}
}
@@ -1,21 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class DisplayViewModel
@Inject
constructor(
private val appStateRepository: AppStateRepository,
) : ViewModel() {
fun onThemeChange(theme: Theme) = viewModelScope.launch {
appStateRepository.setTheme(theme)
}
}
@@ -1,63 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class LogsViewModel
@Inject
constructor(
private val localLogCollector: LogReader,
private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : ViewModel() {
val logs = mutableStateListOf<LogMessage>()
init {
viewModelScope.launch(ioDispatcher) {
localLogCollector.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect {
withContext(mainDispatcher) {
logs.addAll(it)
}
if (logs.size > Constants.LOG_BUFFER_SIZE) {
withContext(mainDispatcher) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
}
}
}
}
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
runCatching {
val file = fileUtils.createNewShareFile("${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
localLogCollector.zipLogFiles(file.absolutePath)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
context.launchShareFile(uri)
}.onFailure {
Timber.Forest.e(it)
}
}
}
@@ -1,269 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.entity.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileReadException
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.extractNameAndNumber
import com.zaneschepke.wireguardautotunnel.util.extensions.hasNumberInParentheses
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import com.zaneschepke.wireguardautotunnel.util.extensions.withData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
import timber.log.Timber
import java.io.InputStream
import java.net.URL
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel
class MainViewModel
@Inject
constructor(
val tunnelManager: TunnelManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceManager: ServiceManager,
appDataRepository: AppDataRepository,
) : BaseViewModel(appDataRepository) {
fun onDelete(tunnel: TunnelConf) = viewModelScope.launch {
appSettings.withData { settings ->
tunnels.withData {
if (it.size == 1 || tunnel.isPrimaryTunnel) {
serviceManager.stopAutoTunnel()
resetTunnelSetting(settings)
}
appDataRepository.tunnels.delete(tunnel)
}
}
}
private fun resetTunnelSetting(appSettings: AppSettings) {
saveAppSettings(
appSettings.copy(
isAutoTunnelEnabled = false,
isAlwaysOnVpnEnabled = false,
),
)
}
fun onExpandedChanged(expanded: Boolean) = viewModelScope.launch {
appDataRepository.appState.setTunnelStatsExpanded(expanded)
}
fun onTunnelStart(tunnelConf: TunnelConf) = viewModelScope.launch {
appSettings.withData {
Timber.Forest.i("Starting tunnel ${tunnelConf.tunName}")
tunnelManager.startTunnel(tunnelConf)
}
}
fun onTunnelStop(tunnelConf: TunnelConf) = viewModelScope.launch {
appSettings.withData {
tunnelManager.stopTunnel(tunnelConf)
}
}
private fun generateQrCodeDefaultName(config: String): String {
return try {
TunnelConf.configFromAmQuick(config).peers[0].endpoint.get().host
} catch (e: Exception) {
Timber.Forest.e(e)
NumberUtils.generateRandomTunnelName()
}
}
private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name
var num = 1
while (tunnels.any { it.tunName == tunnelName }) {
tunnelName = if (!tunnelName.hasNumberInParentheses()) {
"$name($num)"
} else {
val pair = tunnelName.extractNameAndNumber()
"${pair?.first}($num)"
}
num++
}
tunnelName
}
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val amConfig = stream.use { Config.parse(it) }
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
saveTunnel(
TunnelConf(
tunName = tunnelName,
wgQuick = amConfig.toWgQuickString(),
amQuick = amConfig.toAwgQuickString(true),
),
)
}
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
return context.applicationContext.contentResolver.openInputStream(uri)
}
fun onTunnelFileSelected(uri: Uri, context: Context) = viewModelScope.launch(ioDispatcher) {
runCatching {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
val fileName = getFileName(context, uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, context)
Constants.ZIP_FILE_EXTENSION ->
saveTunnelsFromZipUri(
uri,
context,
)
else -> throw InvalidFileExtensionException
}
}.onFailure {
Timber.Forest.e(it)
if (it is InvalidFileExtensionException) {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_extension))
} else {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_format))
}
}
}
fun onToggleAutoTunnel() = viewModelScope.launch {
serviceManager.toggleAutoTunnel(false)
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { entry ->
val name = getNameFromFileName(entry.name)
val amConf = Config.parse(zip.bufferedReader())
saveTunnel(
TunnelConf(
tunName = makeTunnelNameUnique(name),
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
),
)
}
}
}
fun setBatteryOptimizeDisableShown() = viewModelScope.launch {
appDataRepository.appState.setBatteryOptimizationDisableShown(true)
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) {
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
saveTunnelConfigFromStream(stream, name)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it)
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) return null
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
val move = cursor.moveToFirst()
if (!move) return null
val index = getDisplayNameColumnIndex(cursor)
if (index == null) return index
return cursor.getString(index)
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String? {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
Timber.Forest.e(e)
null
}
}
fun onCopyTunnel(tunnel: TunnelConf) = viewModelScope.launch {
saveTunnel(
TunnelConf(
tunName = makeTunnelNameUnique(tunnel.tunName),
wgQuick = tunnel.wgQuick,
amQuick = tunnel.amQuick,
),
)
}
fun onClipboardImport(config: String) = viewModelScope.launch(ioDispatcher) {
runCatching {
val amConfig = TunnelConf.configFromAmQuick(config)
val tunnelConf = TunnelConf.tunnelConfigFromAmConfig(amConfig, makeTunnelNameUnique(generateQrCodeDefaultName(config)))
saveTunnel(tunnelConf)
}.onFailure {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_format))
}
}
fun onUrlImport(urlString: String) = viewModelScope.launch(ioDispatcher) {
runCatching {
val url = URL(urlString)
val fileName = urlString.substringAfterLast("/")
if (!fileName.endsWith(Constants.CONF_FILE_EXTENSION)) {
throw InvalidFileExtensionException
}
url.openStream().use { stream ->
saveTunnelConfigFromStream(stream, fileName)
}
}.onFailure {
Timber.Forest.e(it)
when (it) {
is InvalidFileExtensionException -> {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_file_extension))
}
else -> {
SnackbarController.Companion.showMessage(StringValue.StringResource(R.string.error_download_failed))
}
}
}
}
}
@@ -1,65 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ScannerViewModel @Inject
constructor(
private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _success = MutableSharedFlow<Boolean>()
val success = _success.asSharedFlow()
private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll()
var tunnelName = name
var num = 1
while (tunnels.any { it.tunName == tunnelName }) {
tunnelName = "$name($num)"
num++
}
tunnelName
}
}
fun onTunnelQrResult(result: String) = viewModelScope.launch(ioDispatcher) {
runCatching {
val amConfig = TunnelConf.configFromAmQuick(result)
val tunnelConfig = TunnelConf.tunnelConfigFromAmConfig(amConfig, makeTunnelNameUnique(generateQrCodeDefaultName(result)))
appDataRepository.tunnels.save(tunnelConfig)
_success.emit(true)
}.onFailure {
_success.emit(false)
Timber.Forest.e(it)
SnackbarController.showMessage(StringValue.StringResource(R.string.error_invalid_code))
}
}
private fun generateQrCodeDefaultName(config: String): String {
return try {
TunnelConf.configFromAmQuick(config).peers[0].endpoint.get().host
} catch (e: Exception) {
Timber.Forest.e(e)
NumberUtils.generateRandomTunnelName()
}
}
}
@@ -1,45 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import android.content.Context
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
private val fileUtils: FileUtils,
) : ViewModel() {
fun exportAllConfigs(context: Context, configType: ConfigType) = viewModelScope.launch {
runCatching {
val tunnels = appDataRepository.tunnels.getAll()
val (files, shareFileName) = when (configType) {
ConfigType.AMNEZIA -> {
Pair(fileUtils.createAmFiles(tunnels), "am-export_${Instant.now().epochSecond}.zip")
}
ConfigType.WG -> {
Pair(fileUtils.createWgFiles(tunnels), "wg-export_${Instant.now().epochSecond}.zip")
}
}
val shareFile = fileUtils.createNewShareFile(shareFileName)
fileUtils.zipAll(shareFile, files)
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), shareFile)
context.launchShareFile(uri)
}.onFailure {
Timber.e(it)
}
}
}
@@ -1,63 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TunnelAutoTunnelViewModel
@Inject
constructor(
appDataRepository: AppDataRepository,
) : BaseViewModel(appDataRepository) {
fun onDeleteRunSSID(ssid: String, tunnelConfig: TunnelConf) = saveTunnel(
tunnelConfig.copy(
tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(),
),
)
fun onSaveRunSSID(ssid: String, tunnelConf: TunnelConf) = viewModelScope.launch {
if (ssid.isBlank()) return@launch
val trimmed = ssid.trim()
val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed)
if (!tunnelConf.tunnelNetworks.contains(trimmed) &&
tunnelsWithName.isEmpty()
) {
saveTunnel(
tunnelConf.copy(
tunnelNetworks = (tunnelConf.tunnelNetworks + ssid).toMutableList(),
),
)
} else {
SnackbarController.Companion.showMessage(
StringValue.StringResource(
R.string.error_ssid_exists,
),
)
}
}
fun onToggleIsMobileDataTunnel(tunnelConf: TunnelConf) = viewModelScope.launch {
if (tunnelConf.isMobileDataTunnel) {
appDataRepository.tunnels.updateMobileDataTunnel(null)
} else {
appDataRepository.tunnels.updateMobileDataTunnel(tunnelConf)
}
}
fun onToggleIsEthernetTunnel(tunnelConf: TunnelConf) = viewModelScope.launch {
if (tunnelConf.isEthernetTunnel) {
appDataRepository.tunnels.updateEthernetTunnel(null)
} else {
appDataRepository.tunnels.updateEthernetTunnel(tunnelConf)
}
}
}
@@ -1,39 +0,0 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TunnelOptionsViewModel
@Inject
constructor(
appDataRepository: AppDataRepository,
) : BaseViewModel(appDataRepository) {
fun onTogglePrimaryTunnel(tunnelConf: TunnelConf) = viewModelScope.launch {
appDataRepository.tunnels.updatePrimaryTunnel(
when (tunnelConf.isPrimaryTunnel) {
true -> null
false -> tunnelConf
},
)
}
fun onToggleIpv4(tunnelConf: TunnelConf) = saveTunnel(
tunnelConf.copy(
isIpv4Preferred = !tunnelConf.isIpv4Preferred,
),
)
fun onPingIntervalChange(tunnelConf: TunnelConf, interval: String) = saveTunnel(
tunnelConf.copy(pingInterval = if (interval.isBlank()) null else interval.toLong() * 1000),
)
fun onPingCoolDownChange(tunnelConf: TunnelConf, cooldown: String) = saveTunnel(
tunnelConf.copy(pingCooldown = if (cooldown.isBlank()) null else cooldown.toLong() * 1000),
)
}
@@ -1,6 +1,55 @@
package com.zaneschepke.wireguardautotunnel.viewmodel.event
import android.net.Uri
import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
sealed class AppEvent {
data object ToggleLocalLogging : AppEvent()
data object ToggleRestartAtBoot : AppEvent()
data object ToggleVpnKillSwitch : AppEvent()
data object ToggleLanOnKillSwitch : AppEvent()
data object ToggleAppShortcuts : AppEvent()
data object ToggleKernelMode : AppEvent()
data object ToggleAlwaysOn : AppEvent()
data object TogglePinLock : AppEvent()
data class TogglePrimaryTunnel(val tunnel: TunnelConf) : AppEvent()
data class ToggleIpv4Preferred(val tunnel: TunnelConf) : AppEvent()
data class TogglePingTunnelEnabled(val tunnel: TunnelConf) : AppEvent()
data class SetTunnelPingInterval(val tunnel: TunnelConf, val pingInterval: String) : AppEvent()
data class SetTunnelPingCooldown(val tunnel: TunnelConf, val pingCooldown: String) : AppEvent()
data class SetTunnelPingIp(val tunnelConf: TunnelConf, val ip: String) : AppEvent()
data class AddTunnelRunSSID(val ssid: String, val tunnel: TunnelConf) : AppEvent()
data class DeleteTunnelRunSSID(val ssid: String, val tunnel: TunnelConf) : AppEvent()
data class ToggleEthernetTunnel(val tunnel: TunnelConf) : AppEvent()
data class ToggleMobileDataTunnel(val tunnel: TunnelConf) : AppEvent()
data class SetDebounceDelay(val delay: Int) : AppEvent()
data object ToggleAutoTunnel : AppEvent()
data class StartTunnel(val tunnel: TunnelConf) : AppEvent()
data class StopTunnel(val tunnel: TunnelConf) : AppEvent()
data class DeleteTunnel(val tunnel: TunnelConf) : AppEvent()
data class CopyTunnel(val tunnel: TunnelConf) : AppEvent()
data class ImportTunnelFromFile(val data: Uri) : AppEvent()
data class ImportTunnelFromClipboard(val text: String) : AppEvent()
data class ImportTunnelFromUrl(val url: String) : AppEvent()
data class ImportTunnelFromQrCode(val qrCode: String) : AppEvent()
data object ToggleTunnelStatsExpanded : AppEvent()
data object SetBatteryOptimizeDisableShown : AppEvent()
data object SetLocationDisclosureShown : AppEvent()
data class SetLocale(val localeTag: String) : AppEvent()
data class SetTheme(val theme: Theme) : AppEvent()
data object ToggleAutoTunnelOnWifi : AppEvent()
data object ToggleAutoTunnelOnCellular : AppEvent()
data object ToggleAutoTunnelOnEthernet : AppEvent()
data object ToggleStopKillSwitchOnTrusted : AppEvent()
data object ToggleStopTunnelOnNoInternet : AppEvent()
data object ToggleAutoTunnelWildcards : AppEvent()
data object ToggleRootShellWifi : AppEvent()
data class DeleteTrustedSSID(val ssid: String) : AppEvent()
data class SaveTrustedSSID(val ssid: String) : AppEvent()
data class ExportTunnels(val configType: ConfigType) : AppEvent()
data object ExportLogs : AppEvent()
data object ErrorShown : AppEvent()
data object BackStackPopped : AppEvent()
}
+1
View File
@@ -226,4 +226,5 @@
<string name="join_matrix">Join Matrix community</string>
<string name="matrix_url">https://matrix.to/#/#wg-tunnel-space:matrix.org</string>
<string name="dropdown">Dropdown</string>
<string name="add_tunnel">Add tunnel</string>
</resources>
+1 -1
View File
@@ -1,6 +1,6 @@
import org.gradle.api.Project
import java.io.File
import java.util.Properties
import java.util.*
fun Project.getCurrentFlavor(): String {
val taskRequestsStr = gradle.startParameter.taskRequests.toString()
@@ -1,13 +1,11 @@
package com.zaneschepke.networkmonitor
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
@@ -1,9 +1,8 @@
package com.zaneschepke.networkmonitor
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*