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_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L 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 CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip" const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
@ -18,4 +15,6 @@ object Constants {
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs" const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822" const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024 const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L
} }

View File

@ -1,6 +1,13 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver 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.math.BigDecimal
import java.text.DecimalFormat import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -30,3 +37,32 @@ fun BigDecimal.toThreeDecimalPlaceString(): String {
return df.format(this) 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 { interface SettingsRepository {
suspend fun save(settings : Settings) suspend fun save(settings : Settings)
fun getSettings() : Flow<Settings> fun getSettingsFlow() : Flow<Settings>
suspend fun getAll() : List<Settings> suspend fun getAll() : List<Settings>
} }

View File

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

View File

@ -1,5 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.repository 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 { 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 package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao 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 { 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 { //TODO fix connected notification
var didShowConnected = false // launch {
var didShowFailedHandshakeNotification = false // var didShowConnected = false
vpnService.handshakeStatus.collect { // var didShowFailedHandshakeNotification = false
when (it) { // vpnService.handshakeStatus.collect {
HandshakeStatus.NOT_STARTED -> { // when (it) {
} // HandshakeStatus.NOT_STARTED -> {
HandshakeStatus.NEVER_CONNECTED -> { // }
if (!didShowFailedHandshakeNotification) { // HandshakeStatus.NEVER_CONNECTED -> {
launchVpnConnectionFailedNotification( // if (!didShowFailedHandshakeNotification) {
getString(R.string.initial_connection_failure_message) // launchVpnConnectionFailedNotification(
) // getString(R.string.initial_connection_failure_message)
didShowFailedHandshakeNotification = true // )
didShowConnected = false // didShowFailedHandshakeNotification = true
} // didShowConnected = false
} // }
// }
HandshakeStatus.HEALTHY -> { //
if (!didShowConnected) { // HandshakeStatus.HEALTHY -> {
launchVpnConnectedNotification() // if (!didShowConnected) {
didShowConnected = true // launchVpnConnectedNotification()
} // didShowConnected = true
} // }
HandshakeStatus.STALE -> {} // }
HandshakeStatus.UNHEALTHY -> { // HandshakeStatus.STALE -> {}
if (!didShowFailedHandshakeNotification) { // HandshakeStatus.UNHEALTHY -> {
launchVpnConnectionFailedNotification( // if (!didShowFailedHandshakeNotification) {
getString(R.string.lost_connection_failure_message) // launchVpnConnectionFailedNotification(
) // getString(R.string.lost_connection_failure_message)
didShowFailedHandshakeNotification = true // )
didShowConnected = false // didShowFailedHandshakeNotification = true
} // didShowConnected = false
} // }
} // }
} // }
} // }
// }
} }
} }

View File

@ -116,27 +116,19 @@ class TunnelControlTile : TileService() {
} }
private suspend fun updateTileState() { 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 { 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() val config = determineTileTunnel()
setTileDescription( setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available) config?.name ?: this.resources.getString(R.string.no_tunnel_available)
) )
qsTile.updateTile() qsTile.updateTile()
} catch (e: Exception) { } catch (e : Exception) {
Timber.e("Unable to update tile state") Timber.e("Unable to update tile state")
} }
} }

View File

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

View File

@ -5,17 +5,14 @@ import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state: SharedFlow<Tunnel.State> val vpnState: StateFlow<VpnState>
val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus>
fun getState(): Tunnel.State 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.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics 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.config.Config
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants 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.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.module.Kernel
import javax.inject.Inject import com.zaneschepke.wireguardautotunnel.module.Userspace
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel class WireGuardTunnel
@Inject @Inject
@ -33,30 +28,8 @@ constructor(
@Kernel private val kernelBackend: Backend, @Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDao private val settingsRepo: SettingsDao
) : VpnService { ) : VpnService {
private val _tunnelName = MutableStateFlow("") private val _vpnState = MutableStateFlow(VpnState())
override val tunnelName get() = _tunnelName.asStateFlow() override val vpnState: StateFlow<VpnState> = _vpnState.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 scope = CoroutineScope(Dispatchers.IO) 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 { return try {
stopTunnelOnConfigChange(tunnelConfig) stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name) emitTunnelName(tunnelConfig.name)
@ -93,95 +66,83 @@ constructor(
val state = val state =
backend.setState( backend.setState(
this, this,
Tunnel.State.UP, State.UP,
config config
) )
_state.emit(state) emitTunnelState(state)
state state
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") 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) { private suspend fun emitTunnelName(name: String) {
_tunnelName.emit(name) _vpnState.emit(
_vpnState.value.copy(
name = name
)
)
} }
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) { 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() stopTunnel()
} }
} }
override fun getName(): String { override fun getName(): String {
return _tunnelName.value return _vpnState.value.name
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if (getState() == Tunnel.State.UP) { if (getState() == State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, State.DOWN, null)
_state.emit(state) emitTunnelState(state)
} }
} catch (e: BackendException) { } catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
} }
} }
override fun getState(): Tunnel.State { override fun getState(): State {
return backend.getState(this) return backend.getState(this)
} }
override fun onStateChange(state: Tunnel.State) { override fun onStateChange(state: State) {
val tunnel = this val tunnel = this
_state.tryEmit(state) emitTunnelState(state)
if (state == Tunnel.State.UP) { if (state == State.UP) {
statsJob = statsJob =
scope.launch { scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics) emitBackendStatistics(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)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
} }
} }
if (state == Tunnel.State.DOWN) { if (state == State.DOWN) {
if (this::statsJob.isInitialized) { if (this::statsJob.isInitialized) {
statsJob.cancel() 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.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
@ -32,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen 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.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -66,6 +65,7 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
// val activityViewModel = hiltViewModel<ActivityViewModel>() // val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -102,20 +102,16 @@ class MainActivity : AppCompatActivity() {
} }
} }
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message: Int) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = val result =
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = message, message = getString(message),
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
} }
} }
@ -192,86 +188,38 @@ class MainActivity : AppCompatActivity() {
) )
return@Scaffold return@Scaffold
} }
NavHost(navController, startDestination = Routes.Main.name) { NavHost(navController, startDestination = Routes.Main.name) {
composable( composable(
Routes.Main.name, 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 -> MainScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message) showSnackBarMessage(message)
}, navController = navController) }, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name,
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)
)
}
}
}) {
SettingsScreen(padding = padding, showSnackbarMessage = { message -> SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message) showSnackBarMessage(message)
}, focusRequester = focusRequester) }, focusRequester = focusRequester)
} }
composable(Routes.Support.name, enterTransition = { composable(Routes.Support.name,
when (initialState.destination.route) { ) {
Routes.Settings.name, Routes.Main.name -> SupportScreen(padding = padding, focusRequester = focusRequester)
slideInHorizontally( }
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION }, composable("${Routes.Config.name}/{id}") {
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))
}) {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) { 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( ConfigScreen(
viewModel = configViewModel,
navController = navController, navController = navController,
id = id, id = id,
showSnackbarMessage = { message -> showSnackbarMessage = { message ->

View File

@ -93,25 +93,16 @@ import timber.log.Timber
) )
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel,
focusRequester: FocusRequester, focusRequester: FocusRequester,
navController: NavController, navController: NavController,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (Int) -> Unit,
id: String id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.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 showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(false) }
@ -122,6 +113,8 @@ fun ConfigScreen(
} }
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val keyboardActions = val keyboardActions =
KeyboardActions( KeyboardActions(
onDone = { onDone = {
@ -139,23 +132,12 @@ fun ConfigScreen(
val fillMaxWidth = .85f val fillMaxWidth = .85f
val screenPadding = 5.dp 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 = { val applicationButtonText = {
"Tunneling apps: " + "Tunneling apps: " +
if (isAllApplicationsEnabled) { if (uiState.isAllApplicationsEnabled) {
"all" "all"
} else { } 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 isAuthenticated = true
}, },
onError = { error -> onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(R.string.error_authentication_failed)
}, },
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed)) showSnackbarMessage(R.string.error_authentication_failed)
} }
) )
} }
if (showApplicationsDialog) { if (showApplicationsDialog) {
val sortedPackages = val sortedPackages =
remember(packages) { remember(uiState.packages) {
packages.sortedBy { viewModel.getPackageLabel(it) } uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
} }
AlertDialog(onDismissRequest = { AlertDialog(onDismissRequest = {
showApplicationsDialog = false showApplicationsDialog = false
@ -192,7 +174,7 @@ fun ConfigScreen(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f) .fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -207,13 +189,13 @@ fun ConfigScreen(
) { ) {
Text(stringResource(id = R.string.tunnel_all)) Text(stringResource(id = R.string.tunnel_all))
Switch( Switch(
checked = isAllApplicationsEnabled, checked = uiState.isAllApplicationsEnabled,
onCheckedChange = { onCheckedChange = {
viewModel.onAllApplicationsChange(it) viewModel.onAllApplicationsChange(it)
} }
) )
} }
if (!isAllApplicationsEnabled) { if (!uiState.isAllApplicationsEnabled) {
Row( Row(
modifier = modifier =
Modifier Modifier
@ -231,9 +213,9 @@ fun ConfigScreen(
) { ) {
Text(stringResource(id = R.string.include)) Text(stringResource(id = R.string.include))
Checkbox( Checkbox(
checked = include, checked = uiState.include,
onCheckedChange = { onCheckedChange = {
viewModel.onIncludeChange(!include) viewModel.onIncludeChange(!uiState.include)
} }
) )
} }
@ -243,9 +225,9 @@ fun ConfigScreen(
) { ) {
Text(stringResource(id = R.string.exclude)) Text(stringResource(id = R.string.exclude))
Checkbox( Checkbox(
checked = !include, checked = !uiState.include,
onCheckedChange = { onCheckedChange = {
viewModel.onIncludeChange(!include) viewModel.onIncludeChange(!uiState.include)
} }
) )
} }
@ -324,7 +306,7 @@ fun ConfigScreen(
} }
Checkbox( Checkbox(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)), checked = (uiState.checkedPackageNames.contains(pack.packageName)),
onCheckedChange = { onCheckedChange = {
if (it) { if (it) {
viewModel.onAddCheckedPackage( viewModel.onAddCheckedPackage(
@ -362,7 +344,7 @@ fun ConfigScreen(
} }
} }
if (tunnel != null) { if (uiState.tunnel != null) {
Scaffold( Scaffold(
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
@ -371,22 +353,25 @@ fun ConfigScreen(
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged { Modifier
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { .padding(bottom = 90.dp)
fobColor = if (it.isFocused) hoverColor else secondaryColor .onFocusChanged {
} if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
}, fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = { onClick = {
scope.launch { scope.launch {
try { try {
viewModel.onSaveAllChanges() viewModel.onSaveAllChanges()
navController.navigate(Routes.Main.name) navController.navigate(Routes.Main.name)
showSnackbarMessage( showSnackbarMessage(
context.resources.getString(R.string.config_changes_saved) R.string.config_changes_saved
) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
showSnackbarMessage(e.message!!) //TODO fix error handling
//showSnackbarMessage(e.message!!)
} }
} }
}, },
@ -433,30 +418,36 @@ fun ConfigScreen(
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup() modifier = Modifier
.padding(15.dp)
.focusGroup()
) { ) {
SectionTitle( SectionTitle(
stringResource(R.string.interface_), stringResource(R.string.interface_),
padding = screenPadding padding = screenPadding
) )
ConfigurationTextBox( ConfigurationTextBox(
value = tunnelName.value, value = uiState.tunnelName,
onValueChange = { value -> onValueChange = { value ->
viewModel.onTunnelNameChange(value) viewModel.onTunnelNameChange(value)
}, },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.name), label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(), hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester( modifier = baseTextBoxModifier
focusRequester .fillMaxWidth()
) .focusRequester(
focusRequester
)
) )
OutlinedTextField( OutlinedTextField(
modifier = modifier =
baseTextBoxModifier.fillMaxWidth().clickable { baseTextBoxModifier
showAuthPrompt = true .fillMaxWidth()
}, .clickable {
value = proxyInterface.privateKey, showAuthPrompt = true
},
value = uiState.interfaceProxy.privateKey,
visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value -> onValueChange = { value ->
@ -483,10 +474,12 @@ fun ConfigScreen(
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
OutlinedTextField( OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester( modifier = baseTextBoxModifier
FocusRequester.Default .fillMaxWidth()
), .focusRequester(
value = proxyInterface.publicKey, FocusRequester.Default
),
value = uiState.interfaceProxy.publicKey,
enabled = false, enabled = false,
onValueChange = {}, onValueChange = {},
trailingIcon = { trailingIcon = {
@ -494,7 +487,7 @@ fun ConfigScreen(
modifier = Modifier.focusRequester(FocusRequester.Default), modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = { onClick = {
clipboardManager.setText( clipboardManager.setText(
AnnotatedString(proxyInterface.publicKey) AnnotatedString(uiState.interfaceProxy.publicKey)
) )
} }
) { ) {
@ -513,7 +506,7 @@ fun ConfigScreen(
) )
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.addresses, value = uiState.interfaceProxy.addresses,
onValueChange = { value -> onValueChange = { value ->
viewModel.onAddressesChanged(value) viewModel.onAddressesChanged(value)
}, },
@ -526,7 +519,7 @@ fun ConfigScreen(
.padding(end = 5.dp) .padding(end = 5.dp)
) )
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.listenPort, value = uiState.interfaceProxy.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) }, onValueChange = { value -> viewModel.onListenPortChanged(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port), label = stringResource(R.string.listen_port),
@ -536,7 +529,7 @@ fun ConfigScreen(
} }
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.dnsServers, value = uiState.interfaceProxy.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) }, onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers), label = stringResource(R.string.dns_servers),
@ -547,7 +540,7 @@ fun ConfigScreen(
.padding(end = 5.dp) .padding(end = 5.dp)
) )
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.mtu, value = uiState.interfaceProxy.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) }, onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.mtu), label = stringResource(R.string.mtu),
@ -573,7 +566,7 @@ fun ConfigScreen(
} }
} }
} }
proxyPeers.forEachIndexed { index, peer -> uiState.proxyPeers.forEachIndexed { index, peer ->
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 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.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
@ -14,210 +12,100 @@ import com.wireguard.config.Interface
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair 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.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.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy 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.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel(assistedFactory = ConfigViewModelFactory::class)
class ConfigViewModel class ConfigViewModel
@Inject @AssistedInject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepo: SettingsDao private val settingsRepository: SettingsRepository,
@Assisted val tunnelId : String
) : ViewModel() { ) : 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 packageManager = application.packageManager
private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>()) private val _uiState = MutableStateFlow(ConfigUiState())
val checkedPackages get() = _checkedPackages.asStateFlow() val uiState = _uiState.asStateFlow()
private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow()
private val _isAllApplicationsEnabled = MutableStateFlow(false) init {
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 = "")
viewModelScope.launch { viewModelScope.launch {
emitTunnelConfig() val packages = getQueriedPackages("")
emitPeerProxy(PeerProxy()) val tunnelConfig = tunnelConfigRepository.getAll().firstOrNull{ it.id.toString() == tunnelId }
emitInterfaceProxy(InterfaceProxy()) val state = if(tunnelConfig != null) {
emitTunnelConfigName() val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitDefaultTunnelStatus() val proxyPeers = config.peers.map { PeerProxy.from(it) }
emitQueriedPackages("") val proxyInterface = InterfaceProxy.from(config.`interface`)
emitTunnelAllApplicationsEnabled() 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) { fun onTunnelNameChange(name: String) {
_tunnelName.value = name _uiState.value = _uiState.value.copy(
tunnelName = name
)
} }
fun onIncludeChange(include: Boolean) { fun onIncludeChange(include: Boolean) {
_include.value = include _uiState.value = _uiState.value.copy(
include = include
)
} }
fun onAddCheckedPackage(packageName: String) { fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName) _uiState.value = _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName
)
} }
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled _uiState.value = _uiState.value.copy(
isAllApplicationsEnabled = isAllApplicationsEnabled
)
} }
fun onRemoveCheckedPackage(packageName: String) { fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName) _uiState.value = _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName
)
} }
private suspend fun emitSplitTunnelConfiguration(config: Config) { private fun getQueriedPackages(query: String) : List<PackageInfo> {
val excludedApps = config.`interface`.excludedApplications return getAllInternetCapablePackages().filter {
val includedApps = config.`interface`.includedApplications getPackageLabel(it).lowercase().contains(query.lowercase())
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)
} }
} }
@ -241,14 +129,14 @@ constructor(
} }
private fun isAllApplicationsEnabled(): Boolean { private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value return _uiState.value.isAllApplicationsEnabled
} }
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch {
tunnelRepo.save(tunnelConfig) tunnelConfigRepository.save(tunnelConfig)
} }
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
if (tunnelConfig != null) { if (tunnelConfig != null) {
saveConfig(tunnelConfig) saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig) updateSettingsDefaultTunnel(tunnelConfig)
@ -256,23 +144,20 @@ constructor(
} }
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettingsFlow().first()
if (settings.isNotEmpty()) { if (settings.defaultTunnel != null) {
val setting = settings[0] if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
if (setting.defaultTunnel != null) { settingsRepository.save(
if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) { settings.copy(
settingsRepo.save( defaultTunnel = tunnelConfig.toString()
setting.copy(
defaultTunnel = tunnelConfig.toString()
)
) )
} )
} }
} }
} }
private fun buildPeerListFromProxyPeers(): List<Peer> { private fun buildPeerListFromProxyPeers(): List<Peer> {
return _proxyPeers.value.map { return _uiState.value.proxyPeers.map {
val builder = Peer.Builder() val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.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 { private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder() val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim()) builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim()) builder.parseAddresses(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim()) builder.parseDnsServers(_uiState.value.interfaceProxy.privateKey.trim())
if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim()) if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) builder.parseMtu(_uiState.value.interfaceProxy.privateKey.trim())
if (_interface.value.listenPort.isNotEmpty()) { if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort( builder.parseListenPort(
_interface.value.listenPort.trim() _uiState.value.interfaceProxy.listenPort.trim()
) )
} }
if (isAllApplicationsEnabled()) _checkedPackages.value.clear() if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_include.value) builder.includeApplications(_checkedPackages.value) if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_include.value) builder.excludeApplications(_checkedPackages.value) if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
return builder.build() return builder.build()
} }
suspend fun onSaveAllChanges() { fun onSaveAllChanges() = viewModelScope.launch {
try { try {
val peerList = buildPeerListFromProxyPeers() val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface() val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = val tunnelConfig =
_tunnel.value?.copy( _uiState.value.tunnel?.copy(
name = _tunnelName.value, name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString() wgQuick = config.toWgQuickString()
) )
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
@ -324,111 +215,134 @@ constructor(
fun onPeerPublicKeyChange( fun onPeerPublicKeyChange(
index: Int, index: Int,
publicKey: String value: String
) { ) {
_proxyPeers.value[index] = _uiState.value = _uiState.value.copy(
_proxyPeers.value[index].copy( proxyPeers = _uiState.value.proxyPeers.update(index,
publicKey = publicKey _uiState.value.proxyPeers[index].copy(
publicKey = value
)
) )
)
} }
fun onPreSharedKeyChange( fun onPreSharedKeyChange(
index: Int, index: Int,
value: String value: String
) { ) {
_proxyPeers.value[index] = _uiState.value = _uiState.value.copy(
_proxyPeers.value[index].copy( proxyPeers = _uiState.value.proxyPeers.update(index,
preSharedKey = value _uiState.value.proxyPeers.get(index).copy(
preSharedKey = value
)
) )
)
} }
fun onEndpointChange( fun onEndpointChange(
index: Int, index: Int,
value: String value: String
) { ) {
_proxyPeers.value[index] = _uiState.value = _uiState.value.copy(
_proxyPeers.value[index].copy( proxyPeers = _uiState.value.proxyPeers.update(index,
endpoint = value _uiState.value.proxyPeers.get(index).copy(
endpoint = value
)
) )
)
} }
fun onAllowedIpsChange( fun onAllowedIpsChange(
index: Int, index: Int,
value: String value: String
) { ) {
_proxyPeers.value[index] = _uiState.value = _uiState.value.copy(
_proxyPeers.value[index].copy( proxyPeers = _uiState.value.proxyPeers.update(index,
allowedIps = value _uiState.value.proxyPeers.get(index).copy(
allowedIps = value
)
) )
)
} }
fun onPersistentKeepaliveChanged( fun onPersistentKeepaliveChanged(
index: Int, index: Int,
value: String value: String
) { ) {
_proxyPeers.value[index] = _uiState.value = _uiState.value.copy(
_proxyPeers.value[index].copy( proxyPeers = _uiState.value.proxyPeers.update(index,
persistentKeepalive = value _uiState.value.proxyPeers[index].copy(
persistentKeepalive = value
)
) )
)
} }
fun onDeletePeer(index: Int) { fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index) _uiState.value.proxyPeers.removeAt(index)
} }
fun addEmptyPeer() { fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy()) _uiState.value.proxyPeers + PeerProxy()
} }
fun generateKeyPair() { fun generateKeyPair() {
val keyPair = KeyPair() val keyPair = KeyPair()
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(), privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64() publicKey = keyPair.publicKey.toBase64()
) )
)
} }
fun onAddressesChanged(value: String) { fun onAddressesChanged(value: String) {
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
addresses = value addresses = value
) )
)
} }
fun onListenPortChanged(value: String) { fun onListenPortChanged(value: String) {
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
listenPort = value listenPort = value
) )
)
} }
fun onDnsServersChanged(value: String) { fun onDnsServersChanged(value: String) {
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
dnsServers = value dnsServers = value
) )
)
} }
fun onMtuChanged(value: String) { fun onMtuChanged(value: String) {
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
mtu = value mtu = value
) )
)
} }
private fun onInterfacePublicKeyChange(value: String) { private fun onInterfacePublicKeyChange(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
publicKey = value interfaceProxy = _uiState.value.interfaceProxy.copy(
publicKey = value
)
) )
} }
fun onPrivateKeyChange(value: String) { fun onPrivateKeyChange(value: String) {
_interface.value = _uiState.value = _uiState.value.copy(
_interface.value.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(
privateKey = value privateKey = value
) )
)
if (NumberUtils.isValidKey(value)) { if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value)) val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64()) onInterfacePublicKeyChange(pair.publicKey.toBase64())
@ -436,4 +350,16 @@ constructor(
onInterfacePublicKeyChange("") 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.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -85,14 +86,15 @@ import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig 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.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem 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.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -102,7 +104,7 @@ import kotlinx.coroutines.launch
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (Int) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@ -113,15 +115,16 @@ fun MainScreen(
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog 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) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null) LaunchedEffect(uiState.errorEvent){
if(uiState.errorEvent != Error.NONE) {
showSnackbarMessage(uiState.errorEvent.getMessage())
viewModel.emitErrorEventConsumed()
}
}
// Nested scroll for control FAB // Nested scroll for control FAB
val nestedScrollConnection = 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 return intent
} }
} }
) { data -> ) { data ->
if (data == null) return@rememberLauncherForActivityResult if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) { viewModel.onTunnelFileSelected(data)
try {
viewModel.onTunnelFileSelected(data)
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
}
} }
val scanLauncher = val scanLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ScanContract(), contract = ScanContract(),
onResult = { onResult = {
scope.launch { viewModel.onTunnelQrResult(it.contents)
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
when (e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
}
}
}
}
} }
) )
@ -242,11 +225,7 @@ fun MainScreen(
checked: Boolean, checked: Boolean,
tunnel: TunnelConfig tunnel: TunnelConfig
) { ) {
try { if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
} }
Scaffold( 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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@ -316,11 +295,7 @@ fun MainScreen(
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
try { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
} }
.padding(10.dp) .padding(10.dp)
) { ) {
@ -406,20 +381,24 @@ fun MainScreen(
.padding(top = 10.dp) .padding(top = 10.dp)
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
) { ) {
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> items(uiState.tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
val leadingIconColor = ( val leadingIconColor = (
if (tunnelName == tunnel.name) { if (uiState.vpnState.name == tunnel.name && uiState.vpnState.status == Tunnel.State.UP) {
when (handshakeStatus) { uiState.vpnState.statistics?.mapPeerStats()?.map {
HandshakeStatus.HEALTHY -> mint it.value?.handshakeStatus()
HandshakeStatus.UNHEALTHY -> brickRed }.let {
HandshakeStatus.STALE -> corn when {
HandshakeStatus.NOT_STARTED -> Color.Gray it?.all { it == HandshakeStatus.HEALTHY } == true -> mint
HandshakeStatus.NEVER_CONNECTED -> brickRed it?.any { it == HandshakeStatus.STALE } == true -> corn
it?.all { it == HandshakeStatus.NOT_STARTED } == true -> Color.Gray
else -> {
Color.Gray
}
}
} }
} else { } else {
Color.Gray Color.Gray
} })
)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val expanded = val expanded =
remember { remember {
@ -427,7 +406,7 @@ fun MainScreen(
} }
RowListItem( RowListItem(
icon = { icon = {
if (settings.isTunnelConfigDefault(tunnel)) { if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, Icons.Rounded.Star,
stringResource(R.string.status), stringResource(R.string.status),
@ -451,10 +430,9 @@ fun MainScreen(
}, },
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) { if ((uiState.vpnState.status == Tunnel.State.UP) && (tunnel.name == uiState.vpnState.name)) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString(R.string.turn_off_tunnel) R.string.turn_off_tunnel)
)
return@RowListItem return@RowListItem
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@ -462,7 +440,7 @@ fun MainScreen(
}, },
onClick = { onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { 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 expanded.value = !expanded.value
} }
} else { } else {
@ -470,7 +448,7 @@ fun MainScreen(
focusRequester.requestFocus() focusRequester.requestFocus()
} }
}, },
statistics = statistics, statistics = uiState.vpnState.statistics,
expanded = expanded.value, expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv( if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(
@ -478,14 +456,11 @@ fun MainScreen(
) )
) { ) {
Row { Row {
if (!settings.isTunnelConfigDefault(tunnel)) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = { IconButton(onClick = {
if (settings.isAutoTunnelEnabled) { if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( R.string.turn_off_auto)
R.string.turn_off_auto
)
)
} else { } else {
showPrimaryChangeAlertDialog = true showPrimaryChangeAlertDialog = true
} }
@ -514,7 +489,7 @@ fun MainScreen(
} }
} }
} else { } 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 if (!checked) expanded.value = false
@Composable @Composable
@ -529,14 +504,11 @@ fun MainScreen(
) )
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row { Row {
if (!settings.isTunnelConfigDefault(tunnel)) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = { IconButton(onClick = {
if (settings.isAutoTunnelEnabled) { if (uiState.settings.isAutoTunnelEnabled) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( R.string.turn_off_auto)
R.string.turn_off_auto
)
)
} else { } else {
showPrimaryChangeAlertDialog = true showPrimaryChangeAlertDialog = true
} }
@ -550,7 +522,7 @@ fun MainScreen(
IconButton( IconButton(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
onClick = { 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 expanded.value = !expanded.value
} }
} }
@ -558,12 +530,9 @@ fun MainScreen(
Icon(Icons.Rounded.Info, stringResource(R.string.info)) Icon(Icons.Rounded.Info, stringResource(R.string.info))
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { if (uiState.vpnState.status == Tunnel.State.UP && tunnel.name == uiState.vpnState.name) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( R.string.turn_off_tunnel)
R.string.turn_off_tunnel
)
)
} else { } else {
navController.navigate( navController.navigate(
"${Routes.Config.name}/${tunnel.id}" "${Routes.Config.name}/${tunnel.id}"
@ -576,12 +545,9 @@ fun MainScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { if (uiState.vpnState.status == Tunnel.State.UP && tunnel.name == uiState.vpnState.name) {
showSnackbarMessage( showSnackbarMessage(
context.resources.getString( R.string.turn_off_tunnel)
R.string.turn_off_tunnel
)
)
} else { } else {
viewModel.onDelete(tunnel) 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.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R 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.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository 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.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Error
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -29,8 +29,9 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -39,27 +40,24 @@ class MainViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus private val _errorState = MutableStateFlow(Error.NONE)
val tunnelName get() = vpnService.tunnelName
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
val statistics get() = vpnService.statistics
init { val uiState = combine(
viewModelScope.launch(Dispatchers.IO) { settingsRepository.getSettingsFlow(),
settingsRepository.getSettings().collect { tunnelConfigRepository.getTunnelConfigsFlow(),
validateWatcherServiceState(it) vpnService.vpnState,
_settings.emit(it) _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) { private fun validateWatcherServiceState(settings: Settings) {
val watcherState = val watcherState =
@ -77,7 +75,7 @@ constructor(
fun onDelete(tunnel: TunnelConfig) { fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
if (tunnelRepo.count() == 1L) { if (tunnelConfigRepository.count() == 1) {
ServiceManager.stopWatcherService(application.applicationContext) ServiceManager.stopWatcherService(application.applicationContext)
val settings = settingsRepository.getAll() val settings = settingsRepository.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
@ -88,22 +86,20 @@ constructor(
saveSettings(setting) saveSettings(setting)
} }
} }
tunnelRepo.delete(tunnel) tunnelConfigRepository.delete(tunnel)
} }
} }
fun onTunnelStart(tunnelConfig: TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch {
viewModelScope.launch { stopActiveTunnel()
stopActiveTunnel() startTunnel(tunnelConfig)
startTunnel(tunnelConfig)
}
} }
private fun startTunnel(tunnelConfig: TunnelConfig) { private fun startTunnel(tunnelConfig: TunnelConfig) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
} }
private suspend fun stopActiveTunnel() { private fun stopActiveTunnel() = viewModelScope.launch {
if (ServiceManager.getServiceState( if (ServiceManager.getServiceState(
application.applicationContext, application.applicationContext,
WireGuardTunnelService::class.java WireGuardTunnelService::class.java
@ -122,14 +118,14 @@ constructor(
TunnelConfig.configFromQuick(config) TunnelConfig.configFromQuick(config)
} }
suspend fun onTunnelQrResult(result: String) { fun onTunnelQrResult(result: String) = viewModelScope.launch {
try { try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e) emitErrorEvent(Error.INVALID_QR)
} }
} }
@ -151,19 +147,16 @@ constructor(
?: throw WgTunnelException(application.getString(R.string.stream_failed)) ?: throw WgTunnelException(application.getString(R.string.stream_failed))
} }
suspend fun onTunnelFileSelected(uri: Uri) { fun onTunnelFileSelected(uri: Uri) = viewModelScope.launch {
try { try {
val fileName = getFileName(application.applicationContext, uri) val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName) when (getFileExtensionFromFileName(fileName)) {
when (fileExtension) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException( else -> emitErrorEvent(Error.FILE_EXTENSION)
application.getString(R.string.file_extension_message)
)
} }
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e) emitErrorEvent(Error.FILE_EXTENSION)
} }
} }
@ -197,7 +190,7 @@ constructor(
} }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelConfigRepository.save(tunnelConfig)
} }
private fun getFileNameByCursor( private fun getFileNameByCursor(
@ -233,13 +226,16 @@ constructor(
private fun validateUriContentScheme(uri: Uri) { private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) { 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) { fun emitErrorEventConsumed() {
//TODO handle error if fails _errorState.tryEmit(Error.NONE)
settingsRepository.save(settings) }
private fun emitErrorEvent(error : Error) {
_errorState.tryEmit(error)
} }
private fun getFileName( 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) { if (selectedTunnel != null) {
_settings.emit( saveSettings(
_settings.value.copy( uiState.value.settings.copy(
defaultTunnel = selectedTunnel.toString() 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 android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -38,6 +39,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Error
import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.FileUtils
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -88,7 +91,7 @@ import kotlinx.coroutines.launch
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (Int) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
@ -109,8 +112,49 @@ fun SettingsScreen(
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f 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() { fun exportAllConfigs() {
try { try {
@ -122,22 +166,16 @@ fun SettingsScreen(
} }
FileUtils.saveFilesToZip(context, files) FileUtils.saveFilesToZip(context, files)
didExportFiles = true didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message)) showSnackbarMessage(R.string.exported_configs_message)
} catch (e: Exception) { } catch (e: Exception) {
showSnackbarMessage(e.message!!) showSnackbarMessage(Error.GENERAL.getMessage())
} }
} }
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
scope.launch { viewModel.onSaveTrustedSSID(currentText)
try { currentText = ""
viewModel.onSaveTrustedSSID(currentText)
currentText = ""
} catch (e: Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
}
} }
} }
@ -163,9 +201,7 @@ fun SettingsScreen(
isBackgroundLocationGranted = if (!fineLocationState.status.isGranted) { isBackgroundLocationGranted = if (!fineLocationState.status.isGranted) {
false false
} else { } else {
scope.launch { viewModel.setLocationDisclosureShown()
viewModel.setLocationDisclosureShown()
}
true true
} }
} }
@ -194,101 +230,101 @@ fun SettingsScreen(
} }
} }
AnimatedVisibility(!uiState.isLocationDisclosureShown) { AnimatedVisibility(!uiState.isLocationDisclosureShown && !uiState.loading) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = modifier =
Modifier Modifier
.fillMaxSize() .padding(30.dp)
.verticalScroll(scrollState) .size(128.dp)
.padding(padding) )
) { Text(
Icon( stringResource(R.string.prominent_background_location_title),
Icons.Rounded.LocationOff, textAlign = TextAlign.Center,
contentDescription = stringResource(id = R.string.map), modifier = Modifier.padding(30.dp),
modifier = 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 Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp) .padding(30.dp)
.size(128.dp) },
) verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.SpaceEvenly
stringResource(R.string.prominent_background_location_title), ) {
textAlign = TextAlign.Center, TextButton(onClick = {
modifier = Modifier.padding(30.dp), viewModel.setLocationDisclosureShown()
fontSize = 20.sp }) {
) Text(stringResource(id = R.string.no_thanks))
Text( }
stringResource(R.string.prominent_background_location_message), TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
textAlign = TextAlign.Center, openSettings()
modifier = Modifier.padding(30.dp), viewModel.setLocationDisclosureShown()
fontSize = 15.sp }) {
) Text(stringResource(id = R.string.turn_on))
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))
}
} }
} }
} }
}
AnimatedVisibility(showAuthPrompt) { AnimatedVisibility(showAuthPrompt) {
AuthorizationPrompt( AuthorizationPrompt(
onSuccess = { onSuccess = {
showAuthPrompt = false showAuthPrompt = false
exportAllConfigs() exportAllConfigs()
}, },
onError = { error -> onError = { error ->
showSnackbarMessage(error) showAuthPrompt = false
showAuthPrompt = false showSnackbarMessage(R.string.error_authentication_failed)
}, showAuthPrompt = false
onFailure = { },
showAuthPrompt = false onFailure = {
showSnackbarMessage(context.getString(R.string.authentication_failed)) 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()) { if (!uiState.loading && uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
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
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
@ -453,11 +489,11 @@ fun SettingsScreen(
if (!isAllAutoTunnelPermissionsEnabled() && uiState.settings.isTunnelOnWifiEnabled) { if (!isAllAutoTunnelPermissionsEnabled() && uiState.settings.isTunnelOnWifiEnabled) {
val message = val message =
if (!isBackgroundLocationGranted) { if (!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required) R.string.background_location_required
} else if (viewModel.isLocationServicesNeeded()) { } else if (viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required) R.string.location_services_required
} else { } else {
context.getString(R.string.precise_location_required) R.string.precise_location_required
} }
showSnackbarMessage(message) showSnackbarMessage(message)
} else { } else {
@ -499,7 +535,7 @@ fun SettingsScreen(
stringResource(R.string.use_kernel), stringResource(R.string.use_kernel),
enabled = !( enabled = !(
uiState.settings.isAutoTunnelEnabled || uiState.settings.isAlwaysOnVpnEnabled || uiState.settings.isAutoTunnelEnabled || uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.tunnelState == Tunnel.State.UP) (uiState.vpnState.status == Tunnel.State.UP)
), ),
checked = uiState.settings.isKernelEnabled, checked = uiState.settings.isKernelEnabled,
padding = screenPadding, padding = screenPadding,
@ -536,9 +572,7 @@ fun SettingsScreen(
checked = uiState.settings.isAlwaysOnVpnEnabled, checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { onCheckChanged = {
scope.launch { viewModel.onToggleAlwaysOnVPN()
viewModel.onToggleAlwaysOnVPN()
}
} }
) )
ConfigurationToggle( ConfigurationToggle(
@ -547,9 +581,7 @@ fun SettingsScreen(
checked = uiState.settings.isShortcutsEnabled, checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { onCheckChanged = {
scope.launch { viewModel.onToggleShortcutsEnabled()
viewModel.onToggleShortcutsEnabled()
}
} }
) )
Row( Row(
@ -577,3 +609,4 @@ fun SettingsScreen(
} }
} }
} }
}

View File

@ -1,13 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings 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.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.Error
data class SettingsUiState( data class SettingsUiState(
val settings : Settings = Settings(), val settings : Settings = Settings(),
val tunnels : List<TunnelConfig> = emptyList(), val tunnels : List<TunnelConfig> = emptyList(),
val tunnelState : Tunnel.State = Tunnel.State.DOWN, val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown : Boolean = true, val isLocationDisclosureShown : Boolean = true,
val loading : 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.android.util.RootShell 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.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository 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.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -27,22 +29,25 @@ class SettingsViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val dataStoreManager: DataStoreManager, private val dataStoreManager: DataStoreManager,
private val rootShell: RootShell, private val rootShell: RootShell,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
private val _errorState = MutableStateFlow(Error.NONE)
val uiState = combine( val uiState = combine(
settingsRepository.getSettings(), settingsRepository.getSettingsFlow(),
tunnelRepo.getAllFlow(), tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.state, vpnService.vpnState,
dataStoreManager.locationDisclosureFlow, dataStoreManager.locationDisclosureFlow,
){ settings, tunnels, tunnelState, locationDisclosure -> _errorState
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ?: false, false) ){ settings, tunnels, tunnelState, locationDisclosure, errorState ->
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ?: false, false, errorState)
}.stateIn(viewModelScope, }.stateIn(viewModelScope,
SharingStarted.WhileSubscribed(5_000L), SettingsUiState()) SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState())
fun onSaveTrustedSSID(ssid: String) { fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim() val trimmed = ssid.trim()
@ -50,10 +55,17 @@ constructor(
uiState.value.settings.trustedNetworkSSIDs.add(trimmed) uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
saveSettings(uiState.value.settings) saveSettings(uiState.value.settings)
} else { } 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 { suspend fun isLocationDisclosureShown() : Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false
} }
@ -76,7 +88,7 @@ constructor(
} }
private suspend fun getDefaultTunnelOrFirst() : String { 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 { fun toggleAutoTunnel() = viewModelScope.launch {
@ -94,9 +106,8 @@ constructor(
) )
} }
suspend fun onToggleAlwaysOnVPN() { fun onToggleAlwaysOnVPN() = viewModelScope.launch {
val updatedSettings = val updatedSettings = uiState.value.settings.copy(
uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
defaultTunnel = getDefaultTunnelOrFirst() defaultTunnel = getDefaultTunnelOrFirst()
) )
@ -163,7 +174,7 @@ constructor(
saveKernelMode(on = true) saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) { } catch (e: RootShell.RootShellException) {
saveKernelMode(on = false) saveKernelMode(on = false)
throw WgTunnelException("Root shell denied!") emitErrorEvent(Error.ROOT_DENIED)
} }
} else { } else {
saveKernelMode(on = false) 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="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</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="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="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
@ -96,8 +96,6 @@
<string name="none">No trusted wifi names</string> <string name="none">No trusted wifi names</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</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="other">Other</string>
<string name="auto_tunneling">Auto-tunneling</string> <string name="auto_tunneling">Auto-tunneling</string>
<string name="select_tunnel">Select tunnel to use</string> <string name="select_tunnel">Select tunnel to use</string>
@ -128,7 +126,7 @@
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</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="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="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string> <string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (beta)</string> <string name="battery_saver">Battery saver (beta)</string>
@ -137,7 +135,6 @@
<string name="precise_location_required">Precise location required</string> <string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string> <string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</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="status">status</string>
<string name="tunnel_on_wifi">Tunnel on untrusted wifi</string> <string name="tunnel_on_wifi">Tunnel on untrusted wifi</string>
<string name="my_email">zanecschepke@gmail.com</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="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="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</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> </resources>

View File

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