mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
16d5586433
Co-authored-by: zaneschepke <dev@zaneschepke.com>
434 lines
18 KiB
Kotlin
434 lines
18 KiB
Kotlin
package com.zaneschepke.wireguardautotunnel.viewmodel
|
|
|
|
import android.net.Uri
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.dokar.sonner.ToastType
|
|
import com.zaneschepke.wireguardautotunnel.R
|
|
import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelCoordinator
|
|
import com.zaneschepke.wireguardautotunnel.core.orchestration.TunnelModeCoordinator
|
|
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelMode
|
|
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
|
|
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.SelectedTunnelsRepository
|
|
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
|
import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
|
|
import com.zaneschepke.wireguardautotunnel.parser.ConfigParseException
|
|
import com.zaneschepke.wireguardautotunnel.service.ServiceManager
|
|
import com.zaneschepke.wireguardautotunnel.service.autotunnel.AutoTunnelStateHolder
|
|
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
|
|
import com.zaneschepke.wireguardautotunnel.ui.state.DisplayTunnelState
|
|
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
|
|
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
|
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
|
|
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
|
|
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue
|
|
import com.zaneschepke.wireguardautotunnel.util.extensions.isHtmlResponse
|
|
import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely
|
|
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
|
|
import io.ktor.client.HttpClient
|
|
import io.ktor.client.request.prepareGet
|
|
import io.ktor.client.statement.bodyAsText
|
|
import java.io.File
|
|
import java.io.IOException
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.awaitAll
|
|
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.withContext
|
|
import org.orbitmvi.orbit.ContainerHost
|
|
import org.orbitmvi.orbit.viewmodel.container
|
|
import timber.log.Timber
|
|
import xyz.teamgravity.pin_lock_compose.PinManager
|
|
|
|
class SharedAppViewModel(
|
|
private val appStateRepository: AppStateRepository,
|
|
private val serviceManager: ServiceManager,
|
|
private val tunnelCoordinator: TunnelCoordinator,
|
|
private val globalEffectRepository: GlobalEffectRepository,
|
|
private val tunnelRepository: TunnelRepository,
|
|
private val settingsRepository: GeneralSettingRepository,
|
|
private val autoTunnelStateHolder: AutoTunnelStateHolder,
|
|
private val selectedTunnelsRepository: SelectedTunnelsRepository,
|
|
private val tunnelModeCoordinator: TunnelModeCoordinator,
|
|
private val httpClient: HttpClient,
|
|
private val fileUtils: FileUtils,
|
|
private val networkUtils: NetworkUtils,
|
|
) : ContainerHost<GlobalAppUiState, LocalSideEffect>, ViewModel() {
|
|
|
|
val globalSideEffect = globalEffectRepository.flow
|
|
|
|
val tunnelsUiState =
|
|
combine(
|
|
tunnelRepository.userTunnelsFlow,
|
|
tunnelCoordinator.backendStatus,
|
|
selectedTunnelsRepository.flow,
|
|
) { tunnels, backendStatus, selectedTuns ->
|
|
val sortedTunnels = tunnels.sortedBy { it.position }
|
|
|
|
val displayStates =
|
|
backendStatus.activeTunnels.mapValues { (_, activeTunnel) ->
|
|
DisplayTunnelState.from(activeTunnel)
|
|
}
|
|
|
|
TunnelsUiState(
|
|
tunnels = sortedTunnels,
|
|
backendStatus = backendStatus,
|
|
displayStates = displayStates,
|
|
selectedTunnels = selectedTuns,
|
|
isLoading = false,
|
|
)
|
|
}
|
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), TunnelsUiState())
|
|
|
|
override val container =
|
|
container<GlobalAppUiState, LocalSideEffect>(
|
|
GlobalAppUiState(),
|
|
buildSettings = { repeatOnSubscribedStopTimeout = 5_000L },
|
|
) {
|
|
intent {
|
|
combine(
|
|
tunnelRepository.userTunnelsFlow
|
|
.map { tuns -> tuns.associate { it.id to it.name } }
|
|
.distinctUntilChanged(),
|
|
settingsRepository.flow,
|
|
autoTunnelStateHolder.active,
|
|
tunnelsUiState
|
|
.map { Pair(it.isLoading, it.selectedTunnels.size) }
|
|
.distinctUntilChanged(),
|
|
appStateRepository.flow,
|
|
) { tunNames, settings, autoTunnelActive, (loading, selectedTunCount), appState
|
|
->
|
|
state.copy(
|
|
theme = settings.theme,
|
|
tunnelMode = settings.tunnelMode,
|
|
locale = settings.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE,
|
|
tunnelNames = tunNames,
|
|
alreadyDonated = settings.alreadyDonated,
|
|
isAutoTunnelActive = autoTunnelActive,
|
|
isLocationDisclosureShown = appState.isLocationDisclosureShown,
|
|
isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown,
|
|
shouldShowDonationSnackbar = appState.shouldShowDonationSnackbar,
|
|
selectedTunnelCount = selectedTunCount,
|
|
pinLockEnabled = settings.isPinLockEnabled,
|
|
isScreenRecordingProtectionEnabled =
|
|
settings.screenRecordingSecurityEnabled,
|
|
isAppLoaded = !loading,
|
|
)
|
|
}
|
|
.collect { newState -> reduce { newState } }
|
|
}
|
|
}
|
|
|
|
fun startTunnel(tunnelConfig: TunnelConfig) = intent {
|
|
if (state.tunnelMode == TunnelMode.VPN) {
|
|
if (!serviceManager.hasVpnPermission())
|
|
return@intent postSideEffect(
|
|
GlobalSideEffect.RequestVpnPermission(TunnelMode.VPN, tunnelConfig)
|
|
)
|
|
}
|
|
tunnelCoordinator.startTunnel(tunnelConfig)
|
|
}
|
|
|
|
fun postSideEffect(localSideEffect: LocalSideEffect) = intent {
|
|
postSideEffect(localSideEffect)
|
|
}
|
|
|
|
fun setLocationDisclosureShown() = intent {
|
|
appStateRepository.setLocationDisclosureShown(true)
|
|
}
|
|
|
|
fun setTheme(theme: Theme) = intent { settingsRepository.updateTheme(theme) }
|
|
|
|
fun setLocale(locale: String) = intent {
|
|
settingsRepository.updateLocale(locale)
|
|
postSideEffect(GlobalSideEffect.ConfigChanged)
|
|
}
|
|
|
|
fun setPinLockEnabled(enabled: Boolean) = intent {
|
|
if (!enabled) PinManager.clearPin()
|
|
settingsRepository.updatePinLockEnabled(enabled)
|
|
}
|
|
|
|
fun stopTunnel(tunnelConfig: TunnelConfig) = intent {
|
|
tunnelCoordinator.stopTunnel(tunnelConfig.id)
|
|
}
|
|
|
|
fun setAppMode(mode: TunnelMode) = intent {
|
|
if (mode == TunnelMode.VPN || mode == TunnelMode.LOCK_DOWN) {
|
|
if (!serviceManager.hasVpnPermission()) {
|
|
return@intent postSideEffect(GlobalSideEffect.RequestVpnPermission(mode, null))
|
|
}
|
|
}
|
|
|
|
tunnelModeCoordinator.changeMode(mode)
|
|
}
|
|
|
|
fun setShouldShowDonationSnackbar(to: Boolean) = intent {
|
|
appStateRepository.setShouldShowDonationSnackbar(to)
|
|
}
|
|
|
|
fun showSnackMessage(message: StringValue, type: ToastType) = intent {
|
|
postGlobalSideEffect(GlobalSideEffect.Snackbar(message, type))
|
|
}
|
|
|
|
suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) {
|
|
globalEffectRepository.post(globalSideEffect)
|
|
}
|
|
|
|
fun authenticated() = intent { reduce { state.copy(isPinVerified = true) } }
|
|
|
|
suspend fun postGlobalSideEffect(sideEffect: GlobalSideEffect) {
|
|
globalEffectRepository.post(sideEffect)
|
|
}
|
|
|
|
fun disableBatteryOptimizationsShown() = intent {
|
|
appStateRepository.setBatteryOptimizationDisableShown(true)
|
|
}
|
|
|
|
fun saveSortChanges(tunnels: List<TunnelConfig>) = intent {
|
|
tunnelRepository.saveAll(tunnels.mapIndexed { index, conf -> conf.copy(position = index) })
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.config_changes_saved),
|
|
ToastType.Success,
|
|
)
|
|
)
|
|
postSideEffect(GlobalSideEffect.PopBackStack)
|
|
}
|
|
|
|
fun sortByLatency(tunnels: List<TunnelConfig>) = intent {
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.pinging_servers),
|
|
ToastType.Info,
|
|
)
|
|
)
|
|
val sortedResult =
|
|
withContext(Dispatchers.IO) {
|
|
tunnels
|
|
.map { tunnel ->
|
|
async {
|
|
val config =
|
|
try {
|
|
tunnel.getConfig()
|
|
} catch (e: Exception) {
|
|
null
|
|
}
|
|
val endpoint = config?.peers?.firstOrNull()?.host
|
|
if (endpoint != null) {
|
|
val latency =
|
|
try {
|
|
val stats = networkUtils.pingWithStats(endpoint, 3)
|
|
if (stats.isReachable) stats.rttAvg else Double.MAX_VALUE
|
|
} catch (_: Exception) {
|
|
Double.MAX_VALUE
|
|
}
|
|
tunnel to latency
|
|
} else {
|
|
tunnel to Double.MAX_VALUE
|
|
}
|
|
}
|
|
}
|
|
.awaitAll()
|
|
.sortedBy { it.second }
|
|
}
|
|
val sortedTunnels = sortedResult.map { it.first }
|
|
val latencies = sortedResult.associate { it.first.id to it.second }
|
|
postSideEffect(LocalSideEffect.LatencySortFinished(sortedTunnels, latencies))
|
|
}
|
|
|
|
fun importTunnelConfigs(configs: Map<QuickConfig, TunnelName>) = intent {
|
|
try {
|
|
val tunnelConfigs = configs.map { (config, name) ->
|
|
TunnelConfig.tunnelConfFromQuick(config, name)
|
|
}
|
|
tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnelNames.map { it.value })
|
|
} catch (e: Exception) {
|
|
if (e is ConfigParseException) {
|
|
postSideEffect(GlobalSideEffect.Snackbar(e.asStringValue(), ToastType.Error))
|
|
} else {
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.config_error),
|
|
ToastType.Error,
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun importFromClipboard(conf: String) {
|
|
importTunnelConfigs(mapOf(conf to null))
|
|
}
|
|
|
|
fun importFromQr(conf: String) = intent { importFromClipboard(conf) }
|
|
|
|
fun promptWgImport(url: String) = intent { reduce { state.copy(pendingWgImportUrl = url) } }
|
|
|
|
fun dismissWgImport() = intent { reduce { state.copy(pendingWgImportUrl = null) } }
|
|
|
|
fun importFromUrl(url: String) = intent {
|
|
reduce { state.copy(pendingWgImportUrl = null) }
|
|
|
|
try {
|
|
httpClient.prepareGet(url).execute { response ->
|
|
if (response.status.value !in 200..299) {
|
|
throw IOException("Server returned error: ${response.status.value}")
|
|
}
|
|
|
|
if (response.isHtmlResponse()) {
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.error_invalid_config_url),
|
|
ToastType.Error,
|
|
)
|
|
)
|
|
return@execute
|
|
}
|
|
val body = response.bodyAsText()
|
|
importFromClipboard(body)
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(e)
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.error_download_failed),
|
|
ToastType.Error,
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
fun importFromUri(uri: Uri) = intent {
|
|
fileUtils
|
|
.readConfigsFromUri(uri)
|
|
.onSuccess { configs -> importTunnelConfigs(configs) }
|
|
.onFailure {
|
|
val message =
|
|
when (it) {
|
|
is IOException -> StringValue.StringResource(R.string.error_download_failed)
|
|
else -> StringValue.StringResource(R.string.error_file_extension)
|
|
}
|
|
postSideEffect(GlobalSideEffect.Snackbar(message, ToastType.Error))
|
|
}
|
|
}
|
|
|
|
fun toggleSelectAllTunnels() = intent {
|
|
if (state.selectedTunnelCount != state.tunnelNames.size) {
|
|
val tunnels = tunnelRepository.getAll()
|
|
selectedTunnelsRepository.set(tunnels)
|
|
return@intent
|
|
}
|
|
selectedTunnelsRepository.clear()
|
|
}
|
|
|
|
fun clearSelectedTunnels() = intent { selectedTunnelsRepository.clear() }
|
|
|
|
fun toggleSelectedTunnel(tunnelId: Int) = intent {
|
|
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 =
|
|
tunnelCoordinator.backendStatus.firstOrNull()?.activeTunnels?.map { it.key }
|
|
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),
|
|
ToastType.Error,
|
|
)
|
|
)
|
|
tunnelRepository.delete(selectedTuns)
|
|
clearSelectedTunnels()
|
|
}
|
|
|
|
fun copySelectedTunnel() = intent {
|
|
val selected = tunnelsUiState.value.selectedTunnels.firstOrNull() ?: return@intent
|
|
val copy = TunnelConfig.tunnelConfFromQuick(selected.quickConfig, selected.name)
|
|
tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnelNames.map { it.value })
|
|
clearSelectedTunnels()
|
|
}
|
|
|
|
fun exportSelectedTunnels(uri: Uri?) = intent {
|
|
val selectedTunnels = tunnelsUiState.value.selectedTunnels
|
|
val files = createConfFiles(selectedTunnels)
|
|
|
|
val shareFileName = createExportFileName(selectedTunnels.size)
|
|
|
|
val onFailure = { action: Throwable ->
|
|
intent {
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(
|
|
R.string.export_failed,
|
|
": ${action.localizedMessage}",
|
|
),
|
|
ToastType.Error,
|
|
)
|
|
)
|
|
}
|
|
Unit
|
|
}
|
|
|
|
fileUtils
|
|
.createNewShareFile(shareFileName)
|
|
.onSuccess {
|
|
try {
|
|
fileUtils.zipAll(it, files).onFailure(onFailure)
|
|
fileUtils.exportFile(it, uri, FileUtils.ZIP_FILE_MIME_TYPE).onFailure(onFailure)
|
|
} finally {
|
|
if (it.exists()) it.delete()
|
|
}
|
|
postSideEffect(
|
|
GlobalSideEffect.Snackbar(
|
|
StringValue.StringResource(R.string.export_success),
|
|
ToastType.Success,
|
|
)
|
|
)
|
|
clearSelectedTunnels()
|
|
}
|
|
.onFailure(onFailure)
|
|
}
|
|
|
|
private fun createExportFileName(tunnelCount: Int): String {
|
|
val timestamp =
|
|
java.time.LocalDateTime.now()
|
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm"))
|
|
|
|
return when (tunnelCount) {
|
|
1 -> "WGTunnel_Export_$timestamp.zip"
|
|
else -> "WGTunnel_Export_${timestamp}_${tunnelCount}_Tunnels.zip"
|
|
}
|
|
}
|
|
|
|
fun setScreenRecordingSecurity(to: Boolean) = intent {
|
|
settingsRepository.updateScreenRecordingSecurity(to)
|
|
}
|
|
|
|
suspend fun createConfFiles(tunnels: Collection<TunnelConfig>): List<File> =
|
|
tunnels.mapNotNull { config ->
|
|
if (config.quickConfig.isNotBlank()) {
|
|
fileUtils.createFile(config.name, config.quickConfig)
|
|
} else null
|
|
}
|
|
}
|