refactor: ui state optimizations

This commit is contained in:
Zane Schepke
2025-12-25 00:06:53 -05:00
parent e475fd27d9
commit bbc62a26e7
21 changed files with 252 additions and 141 deletions
@@ -300,9 +300,7 @@ class MainActivity : AppCompatActivity() {
}
LaunchedEffect(Unit) {
if (
uiState.shouldShowDonationSnackbar && !uiState.settings.alreadyDonated
) {
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
viewModel.setShouldShowDonationSnackbar(false)
snackbarState.showSnackbar(
SnackbarInfo(
@@ -333,7 +331,7 @@ class MainActivity : AppCompatActivity() {
)
Box(modifier = Modifier.fillMaxSize()) {
if (uiState.settings.appMode == AppMode.LOCK_DOWN) {
if (uiState.appMode == AppMode.LOCK_DOWN) {
AppAlertBanner(
stringResource(R.string.locked_down)
.uppercase(Locale.current.platformLocale),
@@ -29,6 +29,7 @@ import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.core.component.KoinComponent
import org.koin.core.context.GlobalContext.startKoin
import org.koin.core.lazyModules
import org.koin.core.option.viewModelScopeFactory
import org.koin.core.qualifier.named
import timber.log.Timber
@@ -48,6 +49,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
if (BuildConfig.DEBUG) androidLogger()
workManagerFactory()
modules(dispatchersModule, appModule, databaseModule, tunnelModule, workerModule)
options(viewModelScopeFactory())
lazyModules(networkModule)
}
instance = this
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.zaneschepke.wireguardautotunnel.data.entity.GeneralSettings
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import kotlinx.coroutines.flow.Flow
@Dao
@@ -15,4 +16,16 @@ interface GeneralSettingsDao {
@Query("SELECT * FROM general_settings LIMIT 1")
fun getGeneralSettingsFlow(): Flow<GeneralSettings?>
@Query("UPDATE general_settings SET theme = :theme WHERE id = 1")
suspend fun updateTheme(theme: String)
@Query("UPDATE general_settings SET locale = :locale WHERE id = 1")
suspend fun updateLocale(locale: String)
@Query("UPDATE general_settings SET is_pin_lock_enabled = :enabled WHERE id = 1")
suspend fun updatePinLockEnabled(enabled: Boolean)
@Query("UPDATE general_settings SET app_mode = :appMode WHERE id = 1")
suspend fun updateAppMode(appMode: AppMode)
}
@@ -4,31 +4,48 @@ import com.zaneschepke.wireguardautotunnel.data.dao.GeneralSettingsDao
import com.zaneschepke.wireguardautotunnel.data.entity.GeneralSettings as Entity
import com.zaneschepke.wireguardautotunnel.data.mapper.toDomain
import com.zaneschepke.wireguardautotunnel.data.mapper.toEntity
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings as Domain
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class RoomSettingsRepository(
private val settingsDoa: GeneralSettingsDao,
private val settingsDao: GeneralSettingsDao,
private val ioDispatcher: CoroutineDispatcher,
) : GeneralSettingRepository {
override suspend fun upsert(generalSettings: Domain) {
withContext(ioDispatcher) { settingsDoa.upsert(generalSettings.toEntity()) }
withContext(ioDispatcher) { settingsDao.upsert(generalSettings.toEntity()) }
}
override val flow =
settingsDoa
settingsDao
.getGeneralSettingsFlow()
.map { (it ?: Entity()).toDomain() }
.flowOn(ioDispatcher)
override suspend fun getGeneralSettings(): Domain {
return withContext(ioDispatcher) {
(settingsDoa.getGeneralSettings() ?: Entity()).toDomain()
(settingsDao.getGeneralSettings() ?: Entity()).toDomain()
}
}
override suspend fun updateTheme(theme: Theme) {
withContext(ioDispatcher) { settingsDao.updateTheme(theme.name) }
}
override suspend fun updateLocale(locale: String) {
withContext(ioDispatcher) { settingsDao.updateLocale(locale) }
}
override suspend fun updatePinLockEnabled(enabled: Boolean) {
withContext(ioDispatcher) { settingsDao.updatePinLockEnabled(enabled) }
}
override suspend fun updateAppMode(appMode: AppMode) {
withContext(ioDispatcher) { settingsDao.updateAppMode(appMode) }
}
}
@@ -11,6 +11,7 @@ import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.shortcut.DynamicShortcutManager
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.SelectedTunnelsRepository
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel
@@ -30,13 +31,17 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.module.dsl.scopedOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.viewmodel.scope.viewModelScope
@OptIn(KoinExperimentalAPI::class)
val appModule = module {
single<CoroutineScope>(named(Scope.APPLICATION)) {
CoroutineScope(SupervisorJob() + get<CoroutineDispatcher>(named(Dispatcher.DEFAULT)))
@@ -59,11 +64,16 @@ val appModule = module {
)
}
single<ShortcutManager> { DynamicShortcutManager(androidContext(), get(named(Dispatcher.IO))) }
single { FileUtils(androidContext(), get(named(Dispatcher.IO))) }
single { NetworkUtils(get(named(Dispatcher.IO))) }
viewModelScope {
scoped { FileUtils(androidContext(), get(named(Dispatcher.IO))) }
scoped<ShortcutManager> {
DynamicShortcutManager(androidContext(), get(named(Dispatcher.IO)))
}
scopedOf(::GlobalEffectRepository)
scopedOf(::SelectedTunnelsRepository)
}
singleOf(::GlobalEffectRepository)
single { NetworkUtils(get(named(Dispatcher.IO))) }
viewModelOf(::AutoTunnelViewModel)
viewModelOf(::ConfigViewModel)
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.domain.repository
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import kotlinx.coroutines.flow.Flow
interface GeneralSettingRepository {
@@ -9,4 +11,12 @@ interface GeneralSettingRepository {
val flow: Flow<GeneralSettings>
suspend fun getGeneralSettings(): GeneralSettings
suspend fun updateTheme(theme: Theme)
suspend fun updateLocale(locale: String)
suspend fun updatePinLockEnabled(enabled: Boolean)
suspend fun updateAppMode(appMode: AppMode)
}
@@ -0,0 +1,27 @@
package com.zaneschepke.wireguardautotunnel.domain.repository
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class SelectedTunnelsRepository {
private val _selectedTunnelsFlow = MutableStateFlow<List<TunnelConfig>>(emptyList())
val flow = _selectedTunnelsFlow.asStateFlow()
fun add(tunnelConfig: TunnelConfig) {
_selectedTunnelsFlow.update { it.toMutableList().apply { add(tunnelConfig) } }
}
fun remove(tunnelConfig: TunnelConfig) {
_selectedTunnelsFlow.update { it.toMutableList().apply { remove(tunnelConfig) } }
}
fun clear() {
_selectedTunnelsFlow.update { emptyList() }
}
fun set(tunnelConfigs: List<TunnelConfig>) {
_selectedTunnelsFlow.update { tunnelConfigs }
}
}
@@ -23,13 +23,13 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route.*
import com.zaneschepke.wireguardautotunnel.ui.navigation.TunnelNetwork
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.state.GlobalAppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.NavbarState
import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
@Composable
fun currentRouteAsNavbarState(
sharedState: SharedAppUiState,
globalState: GlobalAppUiState,
sharedViewModel: SharedAppViewModel,
route: Route?,
navController: NavController,
@@ -37,7 +37,7 @@ fun currentRouteAsNavbarState(
val keyboardController = LocalSoftwareKeyboardController.current
val context = LocalContext.current
return remember(route, sharedState) {
return remember(route, globalState) {
derivedStateOf {
when (route) {
AdvancedAutoTunnel ->
@@ -70,7 +70,7 @@ fun currentRouteAsNavbarState(
NavbarState(
showBottomItems = true,
topTitle =
if (!sharedState.isLocationDisclosureShown) null
if (!globalState.isLocationDisclosureShown) null
else {
context.getString(R.string.auto_tunnel)
},
@@ -238,7 +238,7 @@ fun currentRouteAsNavbarState(
is Config,
is ConfigGlobal -> {
val tunnelName =
if (route is Config) sharedState.tunnels.find { it.id == route.id }?.name
if (route is Config) globalState.tunnelNames[route.id]
else context.getString(R.string.global_dns_servers)
NavbarState(
topLeading = {
@@ -266,8 +266,7 @@ fun currentRouteAsNavbarState(
is SplitTunnel,
is SplitTunnelGlobal -> {
val tunnelName =
if (route is SplitTunnel)
sharedState.tunnels.find { it.id == route.id }?.name
if (route is SplitTunnel) globalState.tunnelNames[route.id]
else context.getString(R.string.global_split_tunneling)
NavbarState(
topLeading = {
@@ -337,7 +336,7 @@ fun currentRouteAsNavbarState(
showBottomItems = true,
)
is TunnelSettings -> {
val tunnelName = sharedState.tunnels.find { it.id == route.id }?.name
val tunnelName = globalState.tunnelNames[route.id]
NavbarState(
topLeading = {
IconButton(onClick = { navController.pop() }) {
@@ -369,7 +368,7 @@ fun currentRouteAsNavbarState(
NavbarState(
topTitle = context.getString(R.string.tunnels),
topTrailing = {
when (sharedState.selectedTunnels.size) {
when (globalState.selectedTunnelCount) {
0 ->
Row {
IconButton(onClick = { navController.push(Sort) }) {
@@ -423,7 +422,7 @@ fun currentRouteAsNavbarState(
}
}
if (sharedState.selectedTunnels.size == 1) {
if (globalState.selectedTunnelCount == 1) {
IconButton(
onClick = {
sharedViewModel.postSideEffect(
@@ -69,7 +69,7 @@ fun AutoTunnelScreen(
val navController = LocalNavController.current
val clipboard = rememberClipboardHelper()
val sharedUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val globalUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (uiState.isLoading) return
@@ -123,9 +123,9 @@ fun AutoTunnelScreen(
}
fun onAutoTunnelClick() {
if (!sharedUiState.isBatteryOptimizationShown)
if (!globalUiState.isBatteryOptimizationShown)
return requestDisableBatteryOptimizations()
viewModel.toggleAutoTunnel(sharedUiState.settings.appMode)
viewModel.toggleAutoTunnel(globalUiState.appMode)
}
SurfaceRow(
@@ -69,7 +69,7 @@ fun SettingsScreen(
val locale = Locale.current.platformLocale
val sharedUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val globalUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (uiState.isLoading) return
@@ -86,7 +86,7 @@ fun SettingsScreen(
}
fun performBackupRestore(action: () -> Unit) {
if (sharedUiState.activeTunnels.isNotEmpty() || sharedUiState.isAutoTunnelActive)
if (uiState.tunnelActive || globalUiState.isAutoTunnelActive)
return context.showToast(R.string.all_services_disabled)
showBackupSheet = false
action()
@@ -165,22 +165,22 @@ fun SettingsScreen(
Icons.AutoMirrored.Outlined.CallSplit,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
if (globalUiState.appMode == AppMode.PROXY) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
enabled = !sharedUiState.proxyEnabled,
enabled = globalUiState.appMode != AppMode.PROXY,
title = stringResource(R.string.global_split_tunneling),
trailing = { modifier ->
SwitchWithDivider(
checked = uiState.settings.isGlobalSplitTunnelEnabled,
onClick = { viewModel.setGlobalSplitTunneling(it) },
modifier = modifier,
enabled = !sharedUiState.proxyEnabled,
enabled = globalUiState.appMode != AppMode.PROXY,
)
},
description =
if (sharedUiState.proxyEnabled) {
if (globalUiState.appMode == AppMode.PROXY) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
@@ -211,14 +211,15 @@ fun SettingsScreen(
Icons.Outlined.NetworkPing,
contentDescription = null,
tint =
if (!sharedUiState.proxyEnabled) MaterialTheme.colorScheme.onSurface
if (globalUiState.appMode != AppMode.PROXY)
MaterialTheme.colorScheme.onSurface
else Disabled,
)
},
title = stringResource(R.string.ping_monitor),
enabled = !sharedUiState.proxyEnabled,
enabled = globalUiState.appMode != AppMode.PROXY,
description =
if (sharedUiState.proxyEnabled) {
if (globalUiState.appMode == AppMode.PROXY) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
@@ -230,7 +231,7 @@ fun SettingsScreen(
SwitchWithDivider(
checked = uiState.monitoring.isPingEnabled,
onClick = { viewModel.setPingEnabled(it) },
enabled = !sharedUiState.proxyEnabled,
enabled = globalUiState.appMode != AppMode.PROXY,
modifier = modifier,
)
},
@@ -37,7 +37,9 @@ fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel())
val navController = LocalNavController.current
val clipboard = rememberClipboardHelper()
val sharedState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by sharedViewModel.tunnelsUiState.collectAsStateWithLifecycle()
if (uiState.isLoading) return
var showExportSheet by rememberSaveable { mutableStateOf(false) }
var showImportSheet by rememberSaveable { mutableStateOf(false) }
@@ -148,5 +150,5 @@ fun TunnelsScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel())
)
}
TunnelList(sharedState, Modifier.fillMaxSize(), sharedViewModel)
TunnelList(uiState, Modifier.fillMaxSize(), sharedViewModel)
}
@@ -12,7 +12,11 @@ import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
@@ -25,7 +29,7 @@ import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
@@ -33,7 +37,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TunnelList(
sharedState: SharedAppUiState,
uiState: TunnelsUiState,
modifier: Modifier = Modifier,
viewModel: SharedAppViewModel,
) {
@@ -48,7 +52,7 @@ fun TunnelList(
modifier
.pointerInput(Unit) {
detectTapGestures {
if (sharedState.tunnels.isEmpty()) return@detectTapGestures
if (uiState.tunnels.isEmpty()) return@detectTapGestures
viewModel.clearSelectedTunnels()
}
}
@@ -58,7 +62,7 @@ fun TunnelList(
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
if (sharedState.tunnels.isEmpty()) {
if (uiState.tunnels.isEmpty()) {
item {
GettingStartedLabel(
onClick = { context.openWebUrl(it) },
@@ -66,14 +70,14 @@ fun TunnelList(
)
}
}
items(sharedState.tunnels, key = { it.id }) { tunnel ->
items(uiState.tunnels, key = { it.id }) { tunnel ->
val tunnelState =
remember(sharedState.activeTunnels) {
sharedState.activeTunnels[tunnel.id] ?: TunnelState()
remember(uiState.activeTunnels) {
uiState.activeTunnels[tunnel.id] ?: TunnelState()
}
val selected =
remember(sharedState.selectedTunnels) {
sharedState.selectedTunnels.any { it.id == tunnel.id }
remember(uiState.selectedTunnels) {
uiState.selectedTunnels.any { it.id == tunnel.id }
}
var leadingIconColor by
remember(
@@ -97,7 +101,7 @@ fun TunnelList(
},
title = tunnel.name,
onClick = {
if (sharedState.selectedTunnels.isNotEmpty()) {
if (uiState.selectedTunnels.isNotEmpty()) {
viewModel.toggleSelectedTunnel(tunnel.id)
} else {
navController.push(Route.TunnelSettings(tunnel.id))
@@ -111,8 +115,8 @@ fun TunnelList(
TunnelStatisticsRow(
tunnel,
tunnelState,
sharedState.isPingEnabled,
sharedState.showPingStats,
uiState.isPingEnabled,
uiState.showPingStats,
)
}
} else null,
@@ -28,6 +28,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch
@@ -102,14 +103,14 @@ fun TunnelSettingsScreen(
Icons.AutoMirrored.Outlined.CallSplit,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
if (sharedUiState.appMode == AppMode.PROXY) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
enabled = !sharedUiState.proxyEnabled,
enabled = sharedUiState.appMode != AppMode.PROXY,
title = stringResource(R.string.splt_tunneling),
description =
if (sharedUiState.proxyEnabled) {
if (sharedUiState.appMode == AppMode.PROXY) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
@@ -159,14 +160,14 @@ fun TunnelSettingsScreen(
Icons.Outlined.DataUsage,
contentDescription = null,
tint =
if (sharedUiState.proxyEnabled) Disabled
if (sharedUiState.appMode == AppMode.PROXY) Disabled
else MaterialTheme.colorScheme.onSurface,
)
},
title = stringResource(R.string.metered_tunnel),
enabled = !sharedUiState.proxyEnabled,
enabled = sharedUiState.appMode != AppMode.PROXY,
description =
if (sharedUiState.proxyEnabled) {
if (sharedUiState.appMode == AppMode.PROXY) {
{
DescriptionText(
stringResource(R.string.unavailable_in_mode),
@@ -178,7 +179,7 @@ fun TunnelSettingsScreen(
ThemedSwitch(
checked = tunnel.isMetered,
onClick = { viewModel.setMetered(it) },
enabled = !sharedUiState.proxyEnabled,
enabled = sharedUiState.appMode != AppMode.PROXY,
)
},
onClick = { viewModel.setMetered(!tunnel.isMetered) },
@@ -40,12 +40,12 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) {
val sharedState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val tunnelsUiState by sharedViewModel.tunnelsUiState.collectAsStateWithLifecycle()
val hapticFeedback = LocalHapticFeedback.current
val isTv = LocalIsAndroidTV.current
var sortAscending by rememberSaveable { mutableStateOf<Boolean?>(null) }
var editableTunnels by rememberSaveable { mutableStateOf(sharedState.tunnels) }
var editableTunnels by rememberSaveable { mutableStateOf(tunnelsUiState.tunnels) }
sharedViewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
@@ -63,7 +63,7 @@ fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) {
when (sortAscending) {
true -> editableTunnels.sortedBy { it.name }
false -> editableTunnels.sortedByDescending { it.name }
null -> sharedState.tunnels
null -> tunnelsUiState.tunnels
}
}
else -> Unit
@@ -86,7 +86,9 @@ fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier.pointerInput(Unit) { if (sharedState.tunnels.isEmpty()) return@pointerInput }
Modifier.pointerInput(Unit) {
if (tunnelsUiState.tunnels.isEmpty()) return@pointerInput
}
.overscroll(rememberOverscrollEffect()),
state = lazyListState,
userScrollEnabled = true,
@@ -31,7 +31,7 @@ fun SplitTunnelScreen(
sharedViewModel: SharedAppViewModel = koinActivityViewModel(),
) {
val sharedUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle()
val tunnelsUiState by sharedViewModel.tunnelsUiState.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
var showDialog by remember { mutableStateOf(false) }
@@ -69,7 +69,7 @@ fun SplitTunnelScreen(
SelectTunnelModal(
showDialog,
sharedUiState.tunnels,
tunnelsUiState.tunnels,
onAttest = { conf ->
if (conf == null) return@SelectTunnelModal
effectiveTunnel = conf
@@ -1,26 +1,21 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
data class SharedAppUiState(
data class GlobalAppUiState(
val isAppLoaded: Boolean = false,
val theme: Theme = Theme.AUTOMATIC,
val locale: String = LocaleUtil.OPTION_PHONE_LANGUAGE,
val pinLockEnabled: Boolean = false,
val appMode: AppMode = AppMode.VPN,
val shouldShowDonationSnackbar: Boolean = false,
val tunnels: List<TunnelConfig> = emptyList(),
val selectedTunnels: List<TunnelConfig> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val isPingEnabled: Boolean = false,
val showPingStats: Boolean = false,
val isPinVerified: Boolean = false,
val isAutoTunnelActive: Boolean = false,
val isLocationDisclosureShown: Boolean = false,
val isBatteryOptimizationShown: Boolean = false,
val proxyEnabled: Boolean = false,
val settings: GeneralSettings = GeneralSettings(),
val isAutoTunnelActive: Boolean = false,
val tunnelNames: Map<Int, String> = emptyMap(),
val selectedTunnelCount: Int = 0,
val alreadyDonated: Boolean = false,
val isPinVerified: Boolean = false,
)
@@ -15,4 +15,5 @@ data class SettingUiState(
val globalTunnelConfig: TunnelConfig? = null,
val tunnels: List<TunnelConfig> = emptyList(),
val monitoring: MonitoringSettings = MonitoringSettings(),
val tunnelActive: Boolean = false,
)
@@ -1,8 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
data class TunnelsUiState(
val tunnels: List<TunnelConfig> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val selectedTunnels: List<TunnelConfig> = emptyList(),
val isPingEnabled: Boolean = false,
val showPingStats: Boolean = false,
val isLoading: Boolean = true,
)
@@ -5,17 +5,17 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
suspend fun TunnelRepository.saveTunnelsUniquely(
tunnels: List<TunnelConfig>,
existing: List<TunnelConfig>,
existingNames: List<String>,
) {
val uniqueTunnels = generateUniquelyNamedConfigs(tunnels, existing)
val uniqueTunnels = generateUniquelyNamedConfigs(tunnels, existingNames)
saveAll(uniqueTunnels)
}
private fun generateUniquelyNamedConfigs(
incoming: List<TunnelConfig>,
existing: List<TunnelConfig>,
existingNames: List<String>,
): List<TunnelConfig> {
val usedNames = existing.map { it.name }.toMutableSet()
val usedNames = existingNames.toMutableSet()
val result = mutableListOf<TunnelConfig>()
val regex = Regex("(.+)\\s*\\((\\d+)\\)$")
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository
@@ -11,6 +12,8 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.state.SettingUiState
import java.util.UUID
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.viewmodel.container
@@ -20,6 +23,7 @@ class SettingsViewModel(
private val tunnelsRepository: TunnelRepository,
private val monitoringRepository: MonitoringSettingsRepository,
private val globalEffectRepository: GlobalEffectRepository,
private val tunnelManager: TunnelManager,
) : ContainerHost<SettingUiState, Nothing>, ViewModel() {
override val container =
@@ -33,13 +37,15 @@ class SettingsViewModel(
tunnelsRepository.globalTunnelFlow,
tunnelsRepository.userTunnelsFlow,
monitoringRepository.flow,
) { settings, tunnel, tunnels, monitoring ->
tunnelManager.activeTunnels.map { it.isNotEmpty() }.distinctUntilChanged(),
) { settings, tunnel, tunnels, monitoring, tunnelActive ->
state.copy(
settings = settings,
remoteKey = settings.remoteKey,
isRemoteEnabled = settings.isRemoteControlEnabled,
isPinLockEnabled = settings.isPinLockEnabled,
isLoading = false,
tunnelActive = tunnelActive,
globalTunnelConfig = tunnel,
monitoring = monitoring,
tunnels = tunnels,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
@@ -13,10 +14,12 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.SelectedTunnelsRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.GlobalAppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
@@ -32,9 +35,13 @@ import io.ktor.client.statement.bodyAsText
import java.io.File
import java.io.IOException
import java.time.Instant
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.amnezia.awg.config.BadConfigException
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.viewmodel.container
@@ -48,41 +55,64 @@ class SharedAppViewModel(
private val globalEffectRepository: GlobalEffectRepository,
private val tunnelRepository: TunnelRepository,
private val settingsRepository: GeneralSettingRepository,
private val monitoringSettingsRepository: MonitoringSettingsRepository,
private val selectedTunnelsRepository: SelectedTunnelsRepository,
monitoringSettingsRepository: MonitoringSettingsRepository,
private val rootShellUtils: RootShellUtils,
private val httpClient: HttpClient,
private val fileUtils: FileUtils,
) : ContainerHost<SharedAppUiState, LocalSideEffect>, ViewModel() {
) : ContainerHost<GlobalAppUiState, LocalSideEffect>, ViewModel() {
val globalSideEffect = globalEffectRepository.flow
val tunnelsUiState =
combine(
tunnelRepository.userTunnelsFlow,
monitoringSettingsRepository.flow,
tunnelManager.activeTunnels,
selectedTunnelsRepository.flow,
) { tunnels, monitoringSettings, activeTuns, selectedTuns ->
TunnelsUiState(
tunnels = tunnels,
isPingEnabled = monitoringSettings.isPingEnabled,
showPingStats = monitoringSettings.showDetailedPingStats,
activeTunnels = activeTuns,
selectedTunnels = selectedTuns,
isLoading = false,
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), TunnelsUiState())
override val container =
container<SharedAppUiState, LocalSideEffect>(
SharedAppUiState(),
buildSettings = { repeatOnSubscribedStopTimeout = 5000L },
container<GlobalAppUiState, LocalSideEffect>(
GlobalAppUiState(),
buildSettings = { repeatOnSubscribedStopTimeout = 5_000L },
) {
intent {
combine(
tunnelRepository.userTunnelsFlow,
serviceManager.autoTunnelService.map { it != null },
tunnelRepository.userTunnelsFlow
.map { tuns -> tuns.associate { it.id to it.name } }
.distinctUntilChanged(),
serviceManager.autoTunnelService.map { it != null }.distinctUntilChanged(),
settingsRepository.flow,
tunnelManager.activeTunnels,
monitoringSettingsRepository.flow,
) { tunnels, autoTunnelActive, settings, activeTunnels, monitoring ->
tunnelsUiState
.map { Pair(it.isLoading, it.selectedTunnels.size) }
.distinctUntilChanged(),
appStateRepository.flow,
) { tunNames, autoTunnelActive, settings, (loading, selectedTunCount), appState
->
state.copy(
theme = settings.theme,
appMode = settings.appMode,
locale = settings.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE,
tunnelNames = tunNames,
alreadyDonated = settings.alreadyDonated,
isLocationDisclosureShown = appState.isLocationDisclosureShown,
isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown,
shouldShowDonationSnackbar = appState.shouldShowDonationSnackbar,
selectedTunnelCount = selectedTunCount,
pinLockEnabled = settings.isPinLockEnabled,
isAutoTunnelActive = autoTunnelActive,
settings = settings,
tunnels = tunnels,
activeTunnels = activeTunnels,
isPingEnabled = monitoring.isPingEnabled,
showPingStats = monitoring.showDetailedPingStats,
proxyEnabled =
settings.appMode == AppMode.PROXY ||
settings.appMode == AppMode.LOCK_DOWN,
isAppLoaded = true,
isAppLoaded = !loading,
)
}
.collect { newState -> reduce { newState } }
@@ -94,18 +124,6 @@ class SharedAppViewModel(
}
}
intent {
appStateRepository.flow.collect {
reduce {
state.copy(
isLocationDisclosureShown = it.isLocationDisclosureShown,
isBatteryOptimizationShown = it.isBatteryOptimizationDisableShown,
shouldShowDonationSnackbar = it.shouldShowDonationSnackbar,
)
}
}
}
intent {
tunnelManager.messageEvents.collect { (_, message) ->
postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue()))
@@ -114,7 +132,7 @@ class SharedAppViewModel(
}
fun startTunnel(tunnelConfig: TunnelConfig) = intent {
if (state.settings.appMode == AppMode.VPN) {
if (state.appMode == AppMode.VPN) {
if (!serviceManager.hasVpnPermission())
return@intent postSideEffect(
GlobalSideEffect.RequestVpnPermission(AppMode.VPN, tunnelConfig)
@@ -131,18 +149,16 @@ class SharedAppViewModel(
appStateRepository.setLocationDisclosureShown(true)
}
fun setTheme(theme: Theme) = intent {
settingsRepository.upsert(state.settings.copy(theme = theme))
}
fun setTheme(theme: Theme) = intent { settingsRepository.updateTheme(theme) }
fun setLocale(locale: String) = intent {
settingsRepository.upsert(state.settings.copy(locale = locale))
settingsRepository.updateLocale(locale)
postSideEffect(GlobalSideEffect.ConfigChanged)
}
fun setPinLockEnabled(enabled: Boolean) = intent {
if (!enabled) PinManager.clearPin()
settingsRepository.upsert(state.settings.copy(isPinLockEnabled = enabled))
settingsRepository.updatePinLockEnabled(enabled)
}
fun stopTunnel(tunnelConfig: TunnelConfig) = intent {
@@ -184,7 +200,7 @@ class SharedAppViewModel(
}
}
}
settingsRepository.upsert(state.settings.copy(appMode = appMode))
settingsRepository.updateAppMode(appMode)
}
fun setShouldShowDonationSnackbar(to: Boolean) = intent {
@@ -223,7 +239,7 @@ class SharedAppViewModel(
try {
val tunnelConfigs =
configs.map { (config, name) -> TunnelConfig.tunnelConfFromQuick(config, name) }
tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnels)
tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnelNames.map { it.value })
} catch (_: IOException) {
postSideEffect(
GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.read_failed))
@@ -274,56 +290,58 @@ class SharedAppViewModel(
}
fun toggleSelectAllTunnels() = intent {
if (state.selectedTunnels.size != state.tunnels.size) {
return@intent reduce { state.copy(selectedTunnels = state.tunnels) }
if (state.selectedTunnelCount != state.tunnelNames.size) {
val tunnels = tunnelRepository.getAll()
selectedTunnelsRepository.set(tunnels)
return@intent
}
reduce { state.copy(selectedTunnels = emptyList()) }
selectedTunnelsRepository.clear()
}
fun clearSelectedTunnels() = intent { reduce { state.copy(selectedTunnels = emptyList()) } }
fun clearSelectedTunnels() = intent { selectedTunnelsRepository.clear() }
fun toggleSelectedTunnel(tunnelId: Int) = intent {
reduce {
state.copy(
selectedTunnels =
state.selectedTunnels.toMutableList().apply {
val removed = removeIf { it.id == tunnelId }
if (!removed) addAll(state.tunnels.filter { it.id == tunnelId })
}
)
}
val (selectedTuns, tunnels) = tunnelsUiState.value.run { Pair(selectedTunnels, tunnels) }
val selected =
selectedTuns.toMutableList().apply {
val removed = removeIf { it.id == tunnelId }
if (!removed) addAll(tunnels.filter { it.id == tunnelId })
}
selectedTunnelsRepository.set(selected)
}
fun deleteSelectedTunnels() = intent {
val activeTunIds = tunnelManager.activeTunnels.firstOrNull()?.map { it.key }
if (state.selectedTunnels.any { activeTunIds?.contains(it.id) == true })
val selectedTuns = tunnelsUiState.value.selectedTunnels
if (selectedTuns.any { activeTunIds?.contains(it.id) == true })
return@intent postSideEffect(
GlobalSideEffect.Snackbar(
StringValue.StringResource(R.string.delete_active_message)
)
)
tunnelRepository.delete(state.selectedTunnels)
tunnelRepository.delete(selectedTuns)
clearSelectedTunnels()
}
fun copySelectedTunnel() = intent {
val selected = state.selectedTunnels.firstOrNull() ?: return@intent
val selected = tunnelsUiState.value.selectedTunnels.firstOrNull() ?: return@intent
val copy = TunnelConfig.tunnelConfFromQuick(selected.amQuick, selected.name)
tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnels)
tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnelNames.map { it.value })
clearSelectedTunnels()
}
fun exportSelectedTunnels(configType: ConfigType, uri: Uri?) = intent {
val selectedTunnels = tunnelsUiState.value.selectedTunnels
val (files, shareFileName) =
when (configType) {
ConfigType.AM ->
Pair(
createAmFiles(state.selectedTunnels),
createAmFiles(selectedTunnels),
"am-export_${Instant.now().epochSecond}.zip",
)
ConfigType.WG ->
Pair(
createWgFiles(state.selectedTunnels),
createWgFiles(selectedTunnels),
"wg-export_${Instant.now().epochSecond}.zip",
)
}