refactor state, errors, nav

This commit is contained in:
Zane Schepke 2023-12-27 19:39:46 -05:00
parent a3386552d5
commit 12a9e849a6
26 changed files with 749 additions and 780 deletions

View File

@ -6,9 +6,6 @@ object Constants {
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
@ -18,4 +15,6 @@ object Constants {
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L
}

View File

@ -1,6 +1,13 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import android.content.pm.PackageInfo
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Statistics.PeerStats
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
@ -30,3 +37,32 @@ fun BigDecimal.toThreeDecimalPlaceString(): String {
return df.format(this)
}
fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item }
fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key ->
(this.peer(key))
}
}
fun PeerStats.latestHandshakeSeconds() : Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
}
fun PeerStats.handshakeStatus() : HandshakeStatus {
return this.latestHandshakeSeconds().let {
when {
it == null -> HandshakeStatus.NOT_STARTED
it <= HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.HEALTHY
it > HandshakeStatus.STALE_TIME_LIMIT_SEC -> HandshakeStatus.STALE
else -> {
HandshakeStatus.UNKNOWN
}
}
}
}

View File

@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
suspend fun save(settings : Settings)
fun getSettings() : Flow<Settings>
fun getSettingsFlow() : Flow<Settings>
suspend fun getAll() : List<Settings>
}

View File

@ -10,7 +10,7 @@ class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRep
settingsDoa.save(settings)
}
override fun getSettings(): Flow<Settings> {
override fun getSettingsFlow(): Flow<Settings> {
return settingsDoa.getSettingsFlow()
}

View File

@ -1,5 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository {
fun getTunnelConfigsFlow() : Flow<TunnelConfigs>
suspend fun getAll() : TunnelConfigs
suspend fun save(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count() : Int
}

View File

@ -1,7 +1,28 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow()
}
override suspend fun getAll(): TunnelConfigs {
return tunnelConfigDao.getAll()
}
override suspend fun save(tunnelConfig: TunnelConfig) {
tunnelConfigDao.save(tunnelConfig)
}
override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.delete(tunnelConfig)
}
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
}

View File

@ -75,42 +75,43 @@ class WireGuardTunnelService : ForegroundService() {
}
}
}
launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when (it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.initial_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
if (!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.STALE -> {}
HandshakeStatus.UNHEALTHY -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.lost_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
}
}
//TODO fix connected notification
// launch {
// var didShowConnected = false
// var didShowFailedHandshakeNotification = false
// vpnService.handshakeStatus.collect {
// when (it) {
// HandshakeStatus.NOT_STARTED -> {
// }
// HandshakeStatus.NEVER_CONNECTED -> {
// if (!didShowFailedHandshakeNotification) {
// launchVpnConnectionFailedNotification(
// getString(R.string.initial_connection_failure_message)
// )
// didShowFailedHandshakeNotification = true
// didShowConnected = false
// }
// }
//
// HandshakeStatus.HEALTHY -> {
// if (!didShowConnected) {
// launchVpnConnectedNotification()
// didShowConnected = true
// }
// }
// HandshakeStatus.STALE -> {}
// HandshakeStatus.UNHEALTHY -> {
// if (!didShowFailedHandshakeNotification) {
// launchVpnConnectionFailedNotification(
// getString(R.string.lost_connection_failure_message)
// )
// didShowFailedHandshakeNotification = true
// didShowConnected = false
// }
// }
// }
// }
// }
}
}

View File

@ -116,27 +116,19 @@ class TunnelControlTile : TileService() {
}
private suspend fun updateTileState() {
vpnService.state.collect {
vpnService.vpnState.collect {
when(it.status) {
Tunnel.State.UP -> qsTile.state = Tile.STATE_ACTIVE
Tunnel.State.DOWN -> qsTile.state = Tile.STATE_INACTIVE
else -> qsTile.state = Tile.STATE_UNAVAILABLE
}
try {
when (it) {
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
val config = determineTileTunnel()
setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available)
)
qsTile.updateTile()
} catch (e: Exception) {
} catch (e : Exception) {
Timber.e("Unable to update tile state")
}
}

View File

@ -3,8 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus {
HEALTHY,
STALE,
UNHEALTHY,
NEVER_CONNECTED,
UNKNOWN,
NOT_STARTED
;

View File

@ -5,17 +5,14 @@ import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel()
val state: SharedFlow<Tunnel.State>
val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus>
val vpnState: StateFlow<VpnState>
fun getState(): Tunnel.State
}

View File

@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
data class VpnState(
val status : Tunnel.State = Tunnel.State.DOWN,
val name : String = "",
val statistics : Statistics? = null
)

View File

@ -3,28 +3,23 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.Tunnel.State
import com.wireguard.config.Config
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import javax.inject.Inject
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel
@Inject
@ -33,30 +28,8 @@ constructor(
@Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDao
) : VpnService {
private val _tunnelName = MutableStateFlow("")
override val tunnelName get() = _tunnelName.asStateFlow()
private val _state =
MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1
)
private val _handshakeStatus =
MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private val scope = CoroutineScope(Dispatchers.IO)
@ -85,7 +58,7 @@ constructor(
}
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State {
override suspend fun startTunnel(tunnelConfig: TunnelConfig): State {
return try {
stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name)
@ -93,95 +66,83 @@ constructor(
val state =
backend.setState(
this,
Tunnel.State.UP,
State.UP,
config
)
_state.emit(state)
emitTunnelState(state)
state
} catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN
State.DOWN
}
}
private fun emitTunnelState(state: State) {
_vpnState.tryEmit(
_vpnState.value.copy(
status = state
)
)
}
private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit(
_vpnState.value.copy(
statistics = statistics
)
)
}
private suspend fun emitTunnelName(name: String) {
_tunnelName.emit(name)
_vpnState.emit(
_vpnState.value.copy(
name = name
)
)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) {
stopTunnel()
}
}
override fun getName(): String {
return _tunnelName.value
return _vpnState.value.name
}
override suspend fun stopTunnel() {
try {
if (getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null)
_state.emit(state)
if (getState() == State.UP) {
val state = backend.setState(this, State.DOWN, null)
emitTunnelState(state)
}
} catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}")
}
}
override fun getState(): Tunnel.State {
override fun getState(): State {
return backend.getState(this)
}
override fun onStateChange(state: Tunnel.State) {
override fun onStateChange(state: State) {
val tunnel = this
_state.tryEmit(state)
if (state == Tunnel.State.UP) {
emitTunnelState(state)
if (state == State.UP) {
statsJob =
scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) {
val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics)
statistics.peers().forEach { key ->
val handshakeEpoch =
statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[key] = handshakeEpoch
if (handshakeEpoch == 0L) {
if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if (neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL / 1000).toInt()
}
return@forEach
}
// TODO one day make each peer have their own dedicated status
val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow(
handshakeEpoch
)
if (lastHandshake != null) {
if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.STALE)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
}
}
_lastHandshake.emit(handshakeMap)
emitBackendStatistics(statistics)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
}
if (state == Tunnel.State.DOWN) {
if (state == State.DOWN) {
if (this::statsJob.isInitialized) {
statsJob.cancel()
}
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
}
}
}

View File

@ -10,11 +10,8 @@ import android.view.KeyEvent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
@ -32,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -47,12 +43,15 @@ import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScre
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModel
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModelFactory
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
@ -66,6 +65,7 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContent {
// val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController()
val focusRequester = remember { FocusRequester() }
@ -102,20 +102,16 @@ class MainActivity : AppCompatActivity() {
}
}
fun showSnackBarMessage(message: String) {
fun showSnackBarMessage(message: Int) {
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message,
message = getString(message),
actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short
)
when (result) {
SnackbarResult.ActionPerformed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
@ -192,86 +188,38 @@ class MainActivity : AppCompatActivity() {
)
return@Scaffold
}
NavHost(navController, startDestination = Routes.Main.name) {
composable(
Routes.Main.name,
enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Support.name ->
slideInHorizontally(
initialOffsetX = {
-Constants.SLIDE_IN_TRANSITION_OFFSET
},
animationSpec = tween(
Constants.SLIDE_IN_ANIMATION_DURATION
)
)
else -> {
fadeIn(
animationSpec = tween(
Constants.FADE_IN_ANIMATION_DURATION
)
)
}
}
},
exitTransition = {
ExitTransition.None
}
) {
MainScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, navController = navController)
}
composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) {
Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
Routes.Support.name -> {
slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
}
else -> {
fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
}
}
}) {
composable(Routes.Settings.name,
) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
}
composable(Routes.Support.name, enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
else -> {
fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
}
}
}) { SupportScreen(padding = padding, focusRequester = focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) {
composable(Routes.Support.name,
) {
SupportScreen(padding = padding, focusRequester = focusRequester)
}
composable("${Routes.Config.name}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection
val configViewModel by viewModels<ConfigViewModel>(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<
ConfigViewModelFactory> { factory ->
factory.create(id)
}
}
)
ConfigScreen(
viewModel = configViewModel,
navController = navController,
id = id,
showSnackbarMessage = { message ->

View File

@ -93,25 +93,16 @@ import timber.log.Timber
)
@Composable
fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(),
viewModel: ConfigViewModel,
focusRequester: FocusRequester,
navController: NavController,
showSnackbarMessage: (String) -> Unit,
showSnackbarMessage: (Int) -> Unit,
id: String
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle()
val packages by viewModel.packages.collectAsStateWithLifecycle()
val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle()
val include by viewModel.include.collectAsStateWithLifecycle()
val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle()
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
@ -122,6 +113,8 @@ fun ConfigScreen(
}
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val keyboardActions =
KeyboardActions(
onDone = {
@ -139,23 +132,12 @@ fun ConfigScreen(
val fillMaxWidth = .85f
val screenPadding = 5.dp
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
viewModel.onScreenLoad(id)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
navController.navigate(Routes.Main.name)
}
}
}
val applicationButtonText = {
"Tunneling apps: " +
if (isAllApplicationsEnabled) {
if (uiState.isAllApplicationsEnabled) {
"all"
} else {
"${checkedPackages.size} " + (if (include) "included" else "excluded")
"${uiState.checkedPackageNames.size} " + (if (uiState.include) "included" else "excluded")
}
}
@ -166,20 +148,20 @@ fun ConfigScreen(
isAuthenticated = true
},
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
showSnackbarMessage(R.string.error_authentication_failed)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
showSnackbarMessage(R.string.error_authentication_failed)
}
)
}
if (showApplicationsDialog) {
val sortedPackages =
remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) }
remember(uiState.packages) {
uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
}
AlertDialog(onDismissRequest = {
showApplicationsDialog = false
@ -192,7 +174,7 @@ fun ConfigScreen(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
) {
Column(
modifier = Modifier.fillMaxWidth()
@ -207,13 +189,13 @@ fun ConfigScreen(
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = isAllApplicationsEnabled,
checked = uiState.isAllApplicationsEnabled,
onCheckedChange = {
viewModel.onAllApplicationsChange(it)
}
)
}
if (!isAllApplicationsEnabled) {
if (!uiState.isAllApplicationsEnabled) {
Row(
modifier =
Modifier
@ -231,9 +213,9 @@ fun ConfigScreen(
) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = include,
checked = uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
viewModel.onIncludeChange(!uiState.include)
}
)
}
@ -243,9 +225,9 @@ fun ConfigScreen(
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !include,
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
viewModel.onIncludeChange(!uiState.include)
}
)
}
@ -324,7 +306,7 @@ fun ConfigScreen(
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)),
checked = (uiState.checkedPackageNames.contains(pack.packageName)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(
@ -362,7 +344,7 @@ fun ConfigScreen(
}
}
if (tunnel != null) {
if (uiState.tunnel != null) {
Scaffold(
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
@ -371,22 +353,25 @@ fun ConfigScreen(
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
Modifier
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = {
scope.launch {
try {
viewModel.onSaveAllChanges()
navController.navigate(Routes.Main.name)
showSnackbarMessage(
context.resources.getString(R.string.config_changes_saved)
R.string.config_changes_saved
)
} catch (e: Exception) {
Timber.e(e.message)
showSnackbarMessage(e.message!!)
//TODO fix error handling
//showSnackbarMessage(e.message!!)
}
}
},
@ -433,30 +418,36 @@ fun ConfigScreen(
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup()
modifier = Modifier
.padding(15.dp)
.focusGroup()
) {
SectionTitle(
stringResource(R.string.interface_),
padding = screenPadding
)
ConfigurationTextBox(
value = tunnelName.value,
value = uiState.tunnelName,
onValueChange = { value ->
viewModel.onTunnelNameChange(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
focusRequester
)
modifier = baseTextBoxModifier
.fillMaxWidth()
.focusRequester(
focusRequester
)
)
OutlinedTextField(
modifier =
baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey,
baseTextBoxModifier
.fillMaxWidth()
.clickable {
showAuthPrompt = true
},
value = uiState.interfaceProxy.privateKey,
visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value ->
@ -483,10 +474,12 @@ fun ConfigScreen(
keyboardActions = keyboardActions
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
FocusRequester.Default
),
value = proxyInterface.publicKey,
modifier = baseTextBoxModifier
.fillMaxWidth()
.focusRequester(
FocusRequester.Default
),
value = uiState.interfaceProxy.publicKey,
enabled = false,
onValueChange = {},
trailingIcon = {
@ -494,7 +487,7 @@ fun ConfigScreen(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
clipboardManager.setText(
AnnotatedString(proxyInterface.publicKey)
AnnotatedString(uiState.interfaceProxy.publicKey)
)
}
) {
@ -513,7 +506,7 @@ fun ConfigScreen(
)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.addresses,
value = uiState.interfaceProxy.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
},
@ -526,7 +519,7 @@ fun ConfigScreen(
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.listenPort,
value = uiState.interfaceProxy.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
@ -536,7 +529,7 @@ fun ConfigScreen(
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.dnsServers,
value = uiState.interfaceProxy.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
@ -547,7 +540,7 @@ fun ConfigScreen(
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.mtu,
value = uiState.interfaceProxy.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
@ -573,7 +566,7 @@ fun ConfigScreen(
}
}
}
proxyPeers.forEachIndexed { index, peer ->
uiState.proxyPeers.forEachIndexed { index, peer ->
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,

View File

@ -0,0 +1,20 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import com.zaneschepke.wireguardautotunnel.Packages
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Error
data class ConfigUiState(
val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()),
val interfaceProxy: InterfaceProxy = InterfaceProxy(),
val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true,
val isAllApplicationsEnabled : Boolean = false,
val isLoading: Boolean = true,
val tunnel: TunnelConfig? = null,
val tunnelName: String = "",
val errorEvent: Error = Error.NONE
)

View File

@ -5,8 +5,6 @@ import android.app.Application
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
@ -14,210 +12,100 @@ import com.wireguard.config.Interface
import com.wireguard.config.Peer
import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.removeAt
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.update
import com.zaneschepke.wireguardautotunnel.util.Error
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@HiltViewModel
@HiltViewModel(assistedFactory = ConfigViewModelFactory::class)
class ConfigViewModel
@Inject
@AssistedInject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDao
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
@Assisted val tunnelId : String
) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow()
private var _proxyPeers = MutableStateFlow(mutableStateListOf<PeerProxy>())
val proxyPeers get() = _proxyPeers.asStateFlow()
private var _interface = MutableStateFlow(InterfaceProxy())
val interfaceProxy = _interface.asStateFlow()
private val _packages = MutableStateFlow(emptyList<PackageInfo>())
val packages get() = _packages.asStateFlow()
private val packageManager = application.packageManager
private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>())
val checkedPackages get() = _checkedPackages.asStateFlow()
private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow()
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow()
private val _isAllApplicationsEnabled = MutableStateFlow(false)
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false)
private lateinit var tunnelConfig: TunnelConfig
suspend fun onScreenLoad(id: String) {
if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException(
"Config not found"
)
emitScreenData()
} else {
emitEmptyScreenData()
}
}
private fun emitEmptyScreenData() {
tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
init {
viewModelScope.launch {
emitTunnelConfig()
emitPeerProxy(PeerProxy())
emitInterfaceProxy(InterfaceProxy())
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitTunnelAllApplicationsEnabled()
val packages = getQueriedPackages("")
val tunnelConfig = tunnelConfigRepository.getAll().firstOrNull{ it.id.toString() == tunnelId }
val state = if(tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
var isAllApplicationsEnabled = false
val checkedPackages = if(config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications
} else if(config.`interface`.excludedApplications.isNotEmpty()) {
include = false
config.`interface`.excludedApplications
} else {
isAllApplicationsEnabled = true
emptySet()
}
ConfigUiState(proxyPeers,proxyInterface, packages,checkedPackages.toList(),
include, isAllApplicationsEnabled, false, tunnelConfig, tunnelConfig.name, Error.NONE)
} else {
ConfigUiState(isLoading = false, packages = packages)
}
_uiState.value = state
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
emitInterfaceFromConfig()
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitCurrentPackageConfigurations()
}
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach {
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (_: Exception) {
null
}
}
private suspend fun emitTunnelConfig() {
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name: String) {
_tunnelName.value = name
_uiState.value = _uiState.value.copy(
tunnelName = name
)
}
fun onIncludeChange(include: Boolean) {
_include.value = include
_uiState.value = _uiState.value.copy(
include = include
)
}
fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName)
_uiState.value = _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName
)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
_uiState.value = _uiState.value.copy(
isAllApplicationsEnabled = isAllApplicationsEnabled
)
}
fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName)
_uiState.value = _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName
)
}
private suspend fun emitSplitTunnelConfiguration(config: Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
emitTunnelAllApplicationsDisabled()
determineAppInclusionState(excludedApps, includedApps)
} else {
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun determineAppInclusionState(
excludedApps: Set<String>,
includedApps: Set<String>
) {
if (excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true)
}
private suspend fun emitExcludedAppsExist() {
_include.emit(false)
}
private suspend fun emitCheckedApps(apps: Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_isAllApplicationsEnabled.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_isAllApplicationsEnabled.emit(false)
}
private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
}
fun emitQueriedPackages(query: String) {
viewModelScope.launch(Dispatchers.IO) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
private fun getQueriedPackages(query: String) : List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
}
@ -241,14 +129,14 @@ constructor(
}
private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value
return _uiState.value.isAllApplicationsEnabled
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
@ -256,23 +144,20 @@ constructor(
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings[0]
if (setting.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(
setting.copy(
defaultTunnel = tunnelConfig.toString()
)
val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(
settings.copy(
defaultTunnel = tunnelConfig.toString()
)
}
)
}
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _proxyPeers.value.map {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
@ -287,31 +172,37 @@ constructor(
}
}
fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(
checkedPackageNames = emptyList()
)
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim())
if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if (_interface.value.listenPort.isNotEmpty()) {
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseDnsServers(_uiState.value.interfaceProxy.privateKey.trim())
if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) builder.parseMtu(_uiState.value.interfaceProxy.privateKey.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(
_interface.value.listenPort.trim()
_uiState.value.interfaceProxy.listenPort.trim()
)
}
if (isAllApplicationsEnabled()) _checkedPackages.value.clear()
if (_include.value) builder.includeApplications(_checkedPackages.value)
if (!_include.value) builder.excludeApplications(_checkedPackages.value)
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
return builder.build()
}
suspend fun onSaveAllChanges() {
fun onSaveAllChanges() = viewModelScope.launch {
try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_tunnel.value?.copy(
name = _tunnelName.value,
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString()
)
updateTunnelConfig(tunnelConfig)
@ -324,111 +215,134 @@ constructor(
fun onPeerPublicKeyChange(
index: Int,
publicKey: String
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
publicKey = publicKey
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.update(index,
_uiState.value.proxyPeers[index].copy(
publicKey = value
)
)
)
}
fun onPreSharedKeyChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
preSharedKey = value
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.update(index,
_uiState.value.proxyPeers.get(index).copy(
preSharedKey = value
)
)
)
}
fun onEndpointChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
endpoint = value
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.update(index,
_uiState.value.proxyPeers.get(index).copy(
endpoint = value
)
)
)
}
fun onAllowedIpsChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
allowedIps = value
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.update(index,
_uiState.value.proxyPeers.get(index).copy(
allowedIps = value
)
)
)
}
fun onPersistentKeepaliveChanged(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
persistentKeepalive = value
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.update(index,
_uiState.value.proxyPeers[index].copy(
persistentKeepalive = value
)
)
)
}
fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index)
_uiState.value.proxyPeers.removeAt(index)
}
fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy())
_uiState.value.proxyPeers + PeerProxy()
}
fun generateKeyPair() {
val keyPair = KeyPair()
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
)
}
fun onAddressesChanged(value: String) {
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
addresses = value
)
)
}
fun onListenPortChanged(value: String) {
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
listenPort = value
)
)
}
fun onDnsServersChanged(value: String) {
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
dnsServers = value
)
)
}
fun onMtuChanged(value: String) {
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
mtu = value
)
)
}
private fun onInterfacePublicKeyChange(value: String) {
_interface.value =
_interface.value.copy(
publicKey = value
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
publicKey = value
)
)
}
fun onPrivateKeyChange(value: String) {
_interface.value =
_interface.value.copy(
_uiState.value = _uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(
privateKey = value
)
)
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
@ -436,4 +350,16 @@ constructor(
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(
packages = packages
)
}
}

View File

@ -0,0 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import dagger.assisted.AssistedFactory
@AssistedFactory
interface ConfigViewModelFactory {
fun create(configId: String): ConfigViewModel
}

View File

@ -52,6 +52,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -85,14 +86,15 @@ import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.handshakeStatus
import com.zaneschepke.wireguardautotunnel.mapPeerStats
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import com.zaneschepke.wireguardautotunnel.util.Error
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -102,7 +104,7 @@ import kotlinx.coroutines.launch
fun MainScreen(
viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
showSnackbarMessage: (Int) -> Unit,
navController: NavController
) {
val haptic = LocalHapticFeedback.current
@ -113,15 +115,16 @@ fun MainScreen(
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(
HandshakeStatus.NOT_STARTED
)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState.errorEvent){
if(uiState.errorEvent != Error.NONE) {
showSnackbarMessage(uiState.errorEvent.getMessage())
viewModel.emitErrorEventConsumed()
}
}
// Nested scroll for control FAB
val nestedScrollConnection =
@ -176,41 +179,21 @@ fun MainScreen(
)
}
) {
throw WgTunnelException(context.getString(R.string.no_file_explorer))
showSnackbarMessage(R.string.error_no_file_explorer)
}
return intent
}
}
) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
}
viewModel.onTunnelFileSelected(data)
}
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
scope.launch {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
when (e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
}
}
}
}
viewModel.onTunnelQrResult(it.contents)
}
)
@ -242,11 +225,7 @@ fun MainScreen(
checked: Boolean,
tunnel: TunnelConfig
) {
try {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
Scaffold(
@ -290,7 +269,7 @@ fun MainScreen(
}
}
) {
AnimatedVisibility(tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
AnimatedVisibility(uiState.tunnels.isEmpty() && !uiState.loading, exit = fadeOut(), enter = fadeIn()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
@ -316,11 +295,7 @@ fun MainScreen(
.fillMaxWidth()
.clickable {
showBottomSheet = false
try {
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
}
.padding(10.dp)
) {
@ -406,20 +381,24 @@ fun MainScreen(
.padding(top = 10.dp)
.nestedScroll(nestedScrollConnection)
) {
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
items(uiState.tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
val leadingIconColor = (
if (tunnelName == tunnel.name) {
when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.STALE -> corn
HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed
if (uiState.vpnState.name == tunnel.name && uiState.vpnState.status == Tunnel.State.UP) {
uiState.vpnState.statistics?.mapPeerStats()?.map {
it.value?.handshakeStatus()
}.let {
when {
it?.all { it == HandshakeStatus.HEALTHY } == true -> mint
it?.any { it == HandshakeStatus.STALE } == true -> corn
it?.all { it == HandshakeStatus.NOT_STARTED } == true -> Color.Gray
else -> {
Color.Gray
}
}
}
} else {
Color.Gray
}
)
})
val focusRequester = remember { FocusRequester() }
val expanded =
remember {
@ -427,7 +406,7 @@ fun MainScreen(
}
RowListItem(
icon = {
if (settings.isTunnelConfigDefault(tunnel)) {
if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon(
Icons.Rounded.Star,
stringResource(R.string.status),
@ -451,10 +430,9 @@ fun MainScreen(
},
text = tunnel.name,
onHold = {
if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) {
if ((uiState.vpnState.status == Tunnel.State.UP) && (tunnel.name == uiState.vpnState.name)) {
showSnackbarMessage(
context.resources.getString(R.string.turn_off_tunnel)
)
R.string.turn_off_tunnel)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@ -462,7 +440,7 @@ fun MainScreen(
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
if (uiState.vpnState.status == Tunnel.State.UP && (uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
}
} else {
@ -470,7 +448,7 @@ fun MainScreen(
focusRequester.requestFocus()
}
},
statistics = statistics,
statistics = uiState.vpnState.statistics,
expanded = expanded.value,
rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(
@ -478,14 +456,11 @@ fun MainScreen(
)
) {
Row {
if (!settings.isTunnelConfigDefault(tunnel)) {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if (settings.isAutoTunnelEnabled) {
if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_auto
)
)
R.string.turn_off_auto)
} else {
showPrimaryChangeAlertDialog = true
}
@ -514,7 +489,7 @@ fun MainScreen(
}
}
} else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
val checked = uiState.vpnState.status == Tunnel.State.UP && tunnel.name == uiState.vpnState.name
if (!checked) expanded.value = false
@Composable
@ -529,14 +504,11 @@ fun MainScreen(
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row {
if (!settings.isTunnelConfigDefault(tunnel)) {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if (settings.isAutoTunnelEnabled) {
if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_auto
)
)
R.string.turn_off_auto)
} else {
showPrimaryChangeAlertDialog = true
}
@ -550,7 +522,7 @@ fun MainScreen(
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
if (uiState.vpnState.status == Tunnel.State.UP && (uiState.vpnState.name == tunnel.name)) {
expanded.value = !expanded.value
}
}
@ -558,12 +530,9 @@ fun MainScreen(
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
if (uiState.vpnState.status == Tunnel.State.UP && tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
R.string.turn_off_tunnel)
} else {
navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
@ -576,12 +545,9 @@ fun MainScreen(
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
if (uiState.vpnState.status == Tunnel.State.UP && tunnel.name == uiState.vpnState.name) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
R.string.turn_off_tunnel)
} else {
viewModel.onDelete(tunnel)
}

View File

@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import com.zaneschepke.wireguardautotunnel.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.Error
data class MainUiState(
val settings : Settings = Settings(),
val tunnels : TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(),
val loading : Boolean = true,
val errorEvent : Error = Error.NONE
)

View File

@ -10,16 +10,16 @@ import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Error
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel
@ -29,8 +29,9 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -39,27 +40,24 @@ class MainViewModel
@Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
private val vpnService: VpnService
) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus
val tunnelName get() = vpnService.tunnelName
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
val statistics get() = vpnService.statistics
private val _errorState = MutableStateFlow(Error.NONE)
init {
viewModelScope.launch(Dispatchers.IO) {
settingsRepository.getSettings().collect {
validateWatcherServiceState(it)
_settings.emit(it)
}
}
}
val uiState = combine(
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
_errorState,
){ settings, tunnels, vpnState, errorState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false, errorState)
}.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), MainUiState()
)
private fun validateWatcherServiceState(settings: Settings) {
val watcherState =
@ -77,7 +75,7 @@ constructor(
fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch {
if (tunnelRepo.count() == 1L) {
if (tunnelConfigRepository.count() == 1) {
ServiceManager.stopWatcherService(application.applicationContext)
val settings = settingsRepository.getAll()
if (settings.isNotEmpty()) {
@ -88,22 +86,20 @@ constructor(
saveSettings(setting)
}
}
tunnelRepo.delete(tunnel)
tunnelConfigRepository.delete(tunnel)
}
}
fun onTunnelStart(tunnelConfig: TunnelConfig) {
viewModelScope.launch {
stopActiveTunnel()
startTunnel(tunnelConfig)
}
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch {
stopActiveTunnel()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private suspend fun stopActiveTunnel() {
private fun stopActiveTunnel() = viewModelScope.launch {
if (ServiceManager.getServiceState(
application.applicationContext,
WireGuardTunnelService::class.java
@ -122,14 +118,14 @@ constructor(
TunnelConfig.configFromQuick(config)
}
suspend fun onTunnelQrResult(result: String) {
fun onTunnelQrResult(result: String) = viewModelScope.launch {
try {
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e: Exception) {
throw WgTunnelException(e)
emitErrorEvent(Error.INVALID_QR)
}
}
@ -151,19 +147,16 @@ constructor(
?: throw WgTunnelException(application.getString(R.string.stream_failed))
}
suspend fun onTunnelFileSelected(uri: Uri) {
fun onTunnelFileSelected(uri: Uri) = viewModelScope.launch {
try {
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when (fileExtension) {
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(
application.getString(R.string.file_extension_message)
)
else -> emitErrorEvent(Error.FILE_EXTENSION)
}
} catch (e: Exception) {
throw WgTunnelException(e)
emitErrorEvent(Error.FILE_EXTENSION)
}
}
@ -197,7 +190,7 @@ constructor(
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
tunnelConfigRepository.save(tunnelConfig)
}
private fun getFileNameByCursor(
@ -233,13 +226,16 @@ constructor(
private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
emitErrorEvent(Error.FILE_EXTENSION)
}
}
private suspend fun saveSettings(settings: Settings) {
//TODO handle error if fails
settingsRepository.save(settings)
fun emitErrorEventConsumed() {
_errorState.tryEmit(Error.NONE)
}
private fun emitErrorEvent(error : Error) {
_errorState.tryEmit(error)
}
private fun getFileName(
@ -266,14 +262,17 @@ constructor(
}
}
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
private fun saveSettings(settings: Settings) = viewModelScope.launch {
settingsRepository.save(settings)
}
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if (selectedTunnel != null) {
_settings.emit(
_settings.value.copy(
saveSettings(
uiState.value.settings.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepository.save(_settings.value)
}
}
}

View File

@ -7,6 +7,7 @@ import android.os.Build
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -38,6 +39,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -74,6 +76,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Error
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import java.io.File
import kotlinx.coroutines.Dispatchers
@ -88,7 +91,7 @@ import kotlinx.coroutines.launch
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
showSnackbarMessage: (Int) -> Unit,
focusRequester: FocusRequester
) {
val scope = rememberCoroutineScope { Dispatchers.IO }
@ -109,8 +112,49 @@ fun SettingsScreen(
val screenPadding = 5.dp
val fillMaxWidth = .85f
LaunchedEffect(uiState.errorEvent) {
if (uiState.errorEvent != Error.NONE) {
showSnackbarMessage(uiState.errorEvent.getMessage())
viewModel.emitErrorEventConsumed()
}
}
if(uiState.loading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize()
.verticalScroll(rememberScrollState())
.focusable()
.padding(padding)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
})
.padding(bottom = 25.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
stringResource(R.string.thank_you),
textAlign = TextAlign.Start,
modifier = Modifier.padding(bottom = 20.dp),
fontSize = 16.sp
)
}
}
}
}
//TODO add error collecting and displaying for WGTunnelErrors
fun exportAllConfigs() {
try {
@ -122,22 +166,16 @@ fun SettingsScreen(
}
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
showSnackbarMessage(R.string.exported_configs_message)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
showSnackbarMessage(Error.GENERAL.getMessage())
}
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
scope.launch {
try {
viewModel.onSaveTrustedSSID(currentText)
currentText = ""
} catch (e: Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
}
viewModel.onSaveTrustedSSID(currentText)
currentText = ""
}
}
@ -163,9 +201,7 @@ fun SettingsScreen(
isBackgroundLocationGranted = if (!fineLocationState.status.isGranted) {
false
} else {
scope.launch {
viewModel.setLocationDisclosureShown()
}
viewModel.setLocationDisclosureShown()
true
}
}
@ -194,101 +230,101 @@ fun SettingsScreen(
}
}
AnimatedVisibility(!uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
AnimatedVisibility(!uiState.isLocationDisclosureShown && !uiState.loading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier =
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.turn_on))
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
viewModel.setLocationDisclosureShown()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
}
}
AnimatedVisibility(showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
}
AnimatedVisibility(showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { error ->
showAuthPrompt = false
showSnackbarMessage(R.string.error_authentication_failed)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(R.string.error_authentication_failed)
}
)
}
if (uiState.tunnels.isEmpty() && !uiState.loading && uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
if (uiState.tunnels.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
return
}
}
if (!uiState.loading && uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
@ -453,11 +489,11 @@ fun SettingsScreen(
if (!isAllAutoTunnelPermissionsEnabled() && uiState.settings.isTunnelOnWifiEnabled) {
val message =
if (!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required)
R.string.background_location_required
} else if (viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required)
R.string.location_services_required
} else {
context.getString(R.string.precise_location_required)
R.string.precise_location_required
}
showSnackbarMessage(message)
} else {
@ -499,7 +535,7 @@ fun SettingsScreen(
stringResource(R.string.use_kernel),
enabled = !(
uiState.settings.isAutoTunnelEnabled || uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.tunnelState == Tunnel.State.UP)
(uiState.vpnState.status == Tunnel.State.UP)
),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
@ -536,9 +572,7 @@ fun SettingsScreen(
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleAlwaysOnVPN()
}
viewModel.onToggleAlwaysOnVPN()
}
)
ConfigurationToggle(
@ -547,9 +581,7 @@ fun SettingsScreen(
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
viewModel.onToggleShortcutsEnabled()
}
)
Row(
@ -577,3 +609,4 @@ fun SettingsScreen(
}
}
}
}

View File

@ -1,13 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.Error
data class SettingsUiState(
val settings : Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(),
val tunnelState : Tunnel.State = Tunnel.State.DOWN,
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true,
val loading : Boolean = true,
val errorEvent: Error = Error.NONE
)

View File

@ -7,14 +7,16 @@ import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import com.zaneschepke.wireguardautotunnel.util.Error
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
@ -27,22 +29,25 @@ class SettingsViewModel
@Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
private val dataStoreManager: DataStoreManager,
private val rootShell: RootShell,
private val vpnService: VpnService
) : ViewModel() {
private val _errorState = MutableStateFlow(Error.NONE)
val uiState = combine(
settingsRepository.getSettings(),
tunnelRepo.getAllFlow(),
vpnService.state,
settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
dataStoreManager.locationDisclosureFlow,
){ settings, tunnels, tunnelState, locationDisclosure ->
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ?: false, false)
_errorState
){ settings, tunnels, tunnelState, locationDisclosure, errorState ->
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ?: false, false, errorState)
}.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(5_000L), SettingsUiState())
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState())
fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim()
@ -50,10 +55,17 @@ constructor(
uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
saveSettings(uiState.value.settings)
} else {
throw WgTunnelException("SSID already exists.")
emitErrorEvent(Error.SSID_EXISTS)
}
}
fun emitErrorEventConsumed() {
_errorState.tryEmit(Error.NONE)
}
private fun emitErrorEvent(error : Error) {
_errorState.tryEmit(error)
}
suspend fun isLocationDisclosureShown() : Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false
}
@ -76,7 +88,7 @@ constructor(
}
private suspend fun getDefaultTunnelOrFirst() : String {
return uiState.value.settings.defaultTunnel ?: tunnelRepo.getAll().first().wgQuick
return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().wgQuick
}
fun toggleAutoTunnel() = viewModelScope.launch {
@ -94,9 +106,8 @@ constructor(
)
}
suspend fun onToggleAlwaysOnVPN() {
val updatedSettings =
uiState.value.settings.copy(
fun onToggleAlwaysOnVPN() = viewModelScope.launch {
val updatedSettings = uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
defaultTunnel = getDefaultTunnelOrFirst()
)
@ -163,7 +174,7 @@ constructor(
saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) {
saveKernelMode(on = false)
throw WgTunnelException("Root shell denied!")
emitErrorEvent(Error.ROOT_DENIED)
}
} else {
saveKernelMode(on = false)

View File

@ -0,0 +1,23 @@
package com.zaneschepke.wireguardautotunnel.util
import com.zaneschepke.wireguardautotunnel.R
enum class Error(val code : Int,) {
NONE(0),
SSID_EXISTS(2),
ROOT_DENIED(3),
FILE_EXTENSION(4),
NO_FILE_EXPLORER(5),
INVALID_QR(6),
GENERAL(1);
fun getMessage() : Int {
return when(this.code) {
1 -> R.string.unknown_error
2 -> R.string.error_ssid_exists
3 -> R.string.error_root_denied
4 -> R.string.error_file_extension
5 -> R.string.error_no_file_explorer
6 -> R.string.error_invalid_code
else -> R.string.unknown_error
}
}
}

View File

@ -9,7 +9,7 @@
<string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="file_extension_message">File is not a .conf or .zip</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string>
@ -96,8 +96,6 @@
<string name="none">No trusted wifi names</string>
<string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</string>
<string name="unknown_error_message">An unknown error occurred.</string>
<string name="no_file_app">No file app installed.</string>
<string name="other">Other</string>
<string name="auto_tunneling">Auto-tunneling</string>
<string name="select_tunnel">Select tunnel to use</string>
@ -128,7 +126,7 @@
<string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string>
<string name="authentication_failed">Authentication failed</string>
<string name="error_authentication_failed">Authentication failed</string>
<string name="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (beta)</string>
@ -137,7 +135,6 @@
<string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</string>
<string name="no_file_explorer">No file explorer installed</string>
<string name="status">status</string>
<string name="tunnel_on_wifi">Tunnel on untrusted wifi</string>
<string name="my_email">zanecschepke@gmail.com</string>
@ -154,4 +151,9 @@
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
<string name="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string>
<string name="error_ssid_exists">SSID already exists</string>
<string name="error_root_denied">Root shell denied</string>
<string name="error_no_file_explorer">No file explorer installed</string>
<string name="error_no_scan">No code scanned</string>
<string name="error_invalid_code">Invalid QR code</string>
</resources>

View File

@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.2.5"
const val VERSION_NAME = "3.2.6"
const val JVM_TARGET = "17"
const val VERSION_CODE = 32500
const val VERSION_CODE = 32600
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"