mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
refactor, bugs, edge cases
This commit is contained in:
@@ -107,7 +107,12 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_17
|
||||
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -215,8 +220,6 @@ dependencies {
|
||||
implementation(libs.slf4j.android)
|
||||
implementation(libs.icmp4a)
|
||||
|
||||
|
||||
|
||||
// shizuku
|
||||
implementation(libs.shizuku.api)
|
||||
implementation(libs.shizuku.provider)
|
||||
|
||||
@@ -5,10 +5,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
|
||||
import com.zaneschepke.wireguardautotunnel.data.Queries
|
||||
import java.io.IOException
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.IOException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MigrationTest {
|
||||
|
||||
@@ -70,9 +70,9 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.amnezia.awg.backend.GoBackend.VpnService
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
import org.amnezia.awg.backend.GoBackend.VpnService
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -290,7 +290,12 @@ class MainActivity : AppCompatActivity() {
|
||||
appUiState.tunnels
|
||||
.firstOrNull { it.id == args.id }
|
||||
?.let { config ->
|
||||
TunnelOptionsScreen(config, viewModel, appViewState, appUiState.appSettings)
|
||||
TunnelOptionsScreen(
|
||||
config,
|
||||
viewModel,
|
||||
appViewState,
|
||||
appUiState.appSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<Route.Lock> { PinLockScreen(viewModel) }
|
||||
@@ -311,7 +316,9 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
composable<Route.Sort> { SortScreen(appUiState, viewModel) }
|
||||
composable<Route.TunnelMonitoring>{ TunnelMonitoringScreen(appUiState, viewModel) }
|
||||
composable<Route.TunnelMonitoring> {
|
||||
TunnelMonitoringScreen(appUiState, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import com.wireguard.android.backend.GoBackend
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.worker.ServiceWorker
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
@@ -17,16 +18,12 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
|
||||
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class WireGuardAutoTunnel : Application(), Configuration.Provider {
|
||||
@@ -46,6 +43,8 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
|
||||
|
||||
@Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher
|
||||
|
||||
@Inject lateinit var notificationMonitor: NotificationMonitor
|
||||
|
||||
@Inject lateinit var tunnelManager: TunnelManager
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -80,6 +79,7 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
|
||||
ServiceWorker.start(this)
|
||||
|
||||
applicationScope.launch {
|
||||
launch { notificationMonitor.handleApplicationNotifications() }
|
||||
appDataRepository.appState.getLocale()?.let {
|
||||
withContext(mainDispatcher) { LocaleUtil.changeLocale(it) }
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,9 +8,9 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class KernelReceiver : BroadcastReceiver() {
|
||||
|
||||
+1
-1
@@ -10,9 +10,9 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
+1
-1
@@ -9,10 +9,10 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RemoteControlReceiver : BroadcastReceiver() {
|
||||
|
||||
+1
-1
@@ -10,11 +10,11 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class RestartReceiver : BroadcastReceiver() {
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.zaneschepke.wireguardautotunnel.core.notification
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationMonitor
|
||||
@Inject
|
||||
constructor(
|
||||
private val tunnelManager: TunnelManager,
|
||||
private val notificationManager: NotificationManager,
|
||||
) {
|
||||
suspend fun handleApplicationNotifications() = coroutineScope {
|
||||
launch { handleTunnelErrors() }
|
||||
launch { handleTunnelMessages() }
|
||||
}
|
||||
|
||||
private suspend fun handleTunnelErrors() =
|
||||
tunnelManager.errorEvents.collectLatest { (tunnelConf, error) ->
|
||||
if (!WireGuardAutoTunnel.uiActive.value) {
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
WireGuardNotification.NotificationChannels.VPN,
|
||||
title = StringValue.DynamicString(tunnelConf.name),
|
||||
description =
|
||||
when (error) {
|
||||
is BackendError.BounceFailed -> error.toStringValue()
|
||||
else ->
|
||||
StringValue.StringResource(
|
||||
R.string.tunnel_error_template,
|
||||
error.toStringRes(),
|
||||
)
|
||||
},
|
||||
)
|
||||
notificationManager.show(
|
||||
NotificationManager.TUNNEL_ERROR_NOTIFICATION_ID,
|
||||
notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleTunnelMessages() =
|
||||
tunnelManager.messageEvents.collectLatest { (tunnelConf, message) ->
|
||||
if (!WireGuardAutoTunnel.uiActive.value) {
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
WireGuardNotification.NotificationChannels.VPN,
|
||||
title = StringValue.DynamicString(tunnelConf.name),
|
||||
description = message.toStringValue(),
|
||||
)
|
||||
notificationManager.show(
|
||||
NotificationManager.TUNNEL_MESSAGES_NOTIFICATION_ID,
|
||||
notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -7,9 +7,6 @@ import android.content.ServiceConnection
|
||||
import android.net.VpnService
|
||||
import android.os.IBinder
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
|
||||
@@ -29,9 +26,9 @@ class ServiceManager
|
||||
@Inject
|
||||
constructor(
|
||||
private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@ApplicationScope private val applicationScope: CoroutineScope,
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val applicationScope: CoroutineScope,
|
||||
private val mainDispatcher: CoroutineDispatcher,
|
||||
private val appDataRepository: AppDataRepository,
|
||||
) {
|
||||
|
||||
|
||||
+22
-55
@@ -10,21 +10,21 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.ktor.util.collections.*
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -40,12 +40,12 @@ class TunnelForegroundService : LifecycleService() {
|
||||
|
||||
@Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher
|
||||
|
||||
private val tunnelJobs = ConcurrentHashMap<TunnelConf, Unit>()
|
||||
|
||||
private val jobsMutex = Mutex()
|
||||
@Inject lateinit var appDataRepository: AppDataRepository
|
||||
|
||||
class LocalBinder(val service: TunnelForegroundService) : Binder()
|
||||
|
||||
private val tunnelJobs = ConcurrentMap<TunnelConf, Job>()
|
||||
|
||||
private val binder = LocalBinder(this)
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -78,60 +78,23 @@ class TunnelForegroundService : LifecycleService() {
|
||||
fun start() =
|
||||
lifecycleScope.launch(ioDispatcher) {
|
||||
tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels ->
|
||||
// Synchronize jobs with active tunnels
|
||||
synchronizeJobs(activeTunnels)
|
||||
updateServiceNotification()
|
||||
if (activeTunnels.isEmpty()) {
|
||||
stop()
|
||||
val activeTunConfigs = activeTunnels.keys
|
||||
val obsoleteJobs = tunnelJobs.keys - activeTunConfigs
|
||||
obsoleteJobs.forEach { tunnelConf -> tunnelJobs[tunnelConf]?.cancel() }
|
||||
activeTunConfigs.forEach { tun ->
|
||||
if (tunnelJobs.containsKey(tun)) return@forEach
|
||||
tunnelJobs[tun] = launch { tunnelMonitor.startMonitoring(tun, true) }
|
||||
}
|
||||
updateServiceNotification(activeTunnels)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun synchronizeJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
|
||||
jobsMutex.withLock {
|
||||
// Stop jobs for tunnels that are no longer active
|
||||
stopInactiveJobs(activeTunnels)
|
||||
// Start jobs for new tunnels
|
||||
startNewJobs(activeTunnels)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopInactiveJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
|
||||
// If no active tunnels, clear all jobs
|
||||
if (activeTunnels.isEmpty()) return clearAllJobs()
|
||||
// Stop jobs for tunnels not in activeTunnels
|
||||
val tunnelsToStop = tunnelJobs.keys - activeTunnels.keys
|
||||
tunnelsToStop.forEach { tun -> stopTunnelJobs(tun) }
|
||||
}
|
||||
|
||||
private fun clearAllJobs() {
|
||||
tunnelJobs.keys.forEach { tun ->
|
||||
tunnelMonitor.stopMonitoring(tun)
|
||||
}
|
||||
tunnelJobs.clear()
|
||||
}
|
||||
|
||||
private fun stopTunnelJobs(tun: TunnelConf) {
|
||||
tunnelMonitor.stopMonitoring(tun)
|
||||
tunnelJobs.remove(tun)
|
||||
Timber.d("Stopped all tunnel jobs for ${tun.tunName}")
|
||||
}
|
||||
|
||||
private fun startNewJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
|
||||
val tunnelsToStart = activeTunnels.keys - tunnelJobs.keys
|
||||
tunnelsToStart.forEach { tun ->
|
||||
tunnelMonitor.startMonitoring(lifecycleScope, tun)
|
||||
tunnelJobs[tun] = Unit
|
||||
Timber.d("Started tunnel jobs for ${tun.tunName}")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Would be cool to have this include kill switch
|
||||
private fun updateServiceNotification() {
|
||||
private fun updateServiceNotification(activeTunnels: Map<TunnelConf, TunnelState>) {
|
||||
val notification =
|
||||
when (tunnelJobs.size) {
|
||||
when (activeTunnels.size) {
|
||||
0 -> onCreateNotification()
|
||||
1 -> createTunnelNotification(tunnelJobs.keys.first())
|
||||
1 -> createTunnelNotification(activeTunnels.keys.first())
|
||||
else -> createTunnelsNotification()
|
||||
}
|
||||
ServiceCompat.startForeground(
|
||||
@@ -143,13 +106,17 @@ class TunnelForegroundService : LifecycleService() {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.d("Stop called")
|
||||
tunnelJobs.forEach { it.value.cancel() }
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
tunnelJobs.forEach { it.value.cancel() }
|
||||
serviceManager.handleTunnelServiceDestroy()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
Timber.d("onDestroy")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -184,4 +151,4 @@ class TunnelForegroundService : LifecycleService() {
|
||||
title = getString(R.string.tunnel_starting),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
-98
@@ -61,6 +61,8 @@ class AutoTunnelService : LifecycleService() {
|
||||
|
||||
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
|
||||
|
||||
private val bounceCounts = MutableStateFlow<Map<Int, Int>>(emptyMap())
|
||||
|
||||
private var eventHandlerJob: Job? = null
|
||||
|
||||
private val lastBounceTimes = mutableMapOf<Int, Long>()
|
||||
@@ -107,7 +109,7 @@ class AutoTunnelService : LifecycleService() {
|
||||
with(autoTunnelStateFlow.value) {
|
||||
if (
|
||||
settings.isVpnKillSwitchEnabled &&
|
||||
tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE
|
||||
tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE
|
||||
) {
|
||||
eventHandlerJob?.cancel()
|
||||
val allowedIps =
|
||||
@@ -141,41 +143,74 @@ class AutoTunnelService : LifecycleService() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) {
|
||||
private fun startAutoTunnelStateJob() =
|
||||
lifecycleScope.launch(ioDispatcher) {
|
||||
val networkFlow =
|
||||
debouncedConnectivityStateFlow
|
||||
.flowOn(ioDispatcher)
|
||||
.map(NetworkState::from)
|
||||
.map { StateChange.NetworkChange(it) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val consecutivePingFailures = MutableStateFlow<Map<TunnelConf, Int>>(emptyMap())
|
||||
val settingsFlow =
|
||||
combineSettings().map { StateChange.SettingsChange(it.first, it.second) }
|
||||
|
||||
val networkFlow = debouncedConnectivityStateFlow
|
||||
.flowOn(ioDispatcher)
|
||||
.map(NetworkState::from)
|
||||
.map { StateChange.NetworkChange(it) }
|
||||
val tunnelsFlow =
|
||||
tunnelManager.activeTunnels.map { StateChange.ActiveTunnelsChange(it) }
|
||||
|
||||
val settingsFlow = combineSettings()
|
||||
.map { StateChange.SettingsChange(it.first, it.second) }
|
||||
val monitoringFlow =
|
||||
tunnelManager.activeTunnels
|
||||
.map { map -> map.mapValues { (_, state) -> state.pingStates } }
|
||||
.distinctUntilChanged()
|
||||
.map { StateChange.MonitoringChange(it) }
|
||||
|
||||
val tunnelsFlow = tunnelManager.activeTunnels
|
||||
.map { StateChange.ActiveTunnelsChange(it) }
|
||||
var reevaluationJob: Job? = null
|
||||
|
||||
val monitoringFlow = tunnelManager.activeTunnels.map { map ->
|
||||
map.mapValues { (_, state) -> state.pingStates }
|
||||
}.distinctUntilChanged().map { StateChange.MonitoringChange(it, emptyMap()) }
|
||||
// get everything in sync before we use merge
|
||||
combine(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow) {
|
||||
network,
|
||||
settings,
|
||||
tunnels,
|
||||
monitoring ->
|
||||
autoTunnelStateFlow.update {
|
||||
it.copy(
|
||||
activeTunnels = tunnels.activeTunnels,
|
||||
networkState = network.networkState,
|
||||
settings = settings.settings,
|
||||
tunnels = settings.tunnels,
|
||||
)
|
||||
}
|
||||
}
|
||||
.first()
|
||||
|
||||
var reevaluationJob: Job? = null
|
||||
|
||||
merge(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow)
|
||||
.collect { change ->
|
||||
if(change !is StateChange.ActiveTunnelsChange) {
|
||||
// use merge to limit the noise of a combine and also increase the scalability of auto
|
||||
// tunnel handling new states
|
||||
merge(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow).collect { change ->
|
||||
if (change !is StateChange.ActiveTunnelsChange) {
|
||||
Timber.d("New state changed to ${change.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
when (change) {
|
||||
is StateChange.NetworkChange -> {
|
||||
reevaluationJob?.cancel()
|
||||
val previousState = autoTunnelStateFlow.value
|
||||
autoTunnelStateFlow.update { it.copy(networkState = change.networkState) }
|
||||
// Android late mobile data state change, we can ignore handling this
|
||||
if (
|
||||
isAndroidLateCellularActiveChange(
|
||||
previousState.networkState,
|
||||
change.networkState,
|
||||
)
|
||||
) {
|
||||
Timber.d("Android late cellular active state change")
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
is StateChange.SettingsChange -> {
|
||||
reevaluationJob?.cancel()
|
||||
autoTunnelStateFlow.update { it.copy(settings = change.settings, tunnels = change.tunnels) }
|
||||
autoTunnelStateFlow.update {
|
||||
it.copy(settings = change.settings, tunnels = change.tunnels)
|
||||
}
|
||||
}
|
||||
is StateChange.ActiveTunnelsChange -> {
|
||||
autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) }
|
||||
@@ -184,25 +219,18 @@ class AutoTunnelService : LifecycleService() {
|
||||
is StateChange.MonitoringChange -> {
|
||||
change.pingStates.forEach { (config, pingState) ->
|
||||
Timber.d("Ping state $pingState")
|
||||
if(pingState?.any { !it.value.isReachable } == true){
|
||||
consecutivePingFailures.update { current ->
|
||||
current.toMutableMap().apply {
|
||||
this[config] = this[config]?.let { it + 1 } ?: 1
|
||||
}
|
||||
}
|
||||
Timber.d("Consecutive failures for ${config.name} are ${consecutivePingFailures.value[config]}.")
|
||||
} else {
|
||||
Timber.d("Clearing consecutive failures")
|
||||
consecutivePingFailures.update { current ->
|
||||
current.toMutableMap().apply {
|
||||
remove(config)
|
||||
}
|
||||
if (pingState?.all { it.value.isReachable } == true) {
|
||||
Timber.d("Clearing bounce count on success")
|
||||
bounceCounts.update { current ->
|
||||
current.toMutableMap().apply { remove(config.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return@collect handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(
|
||||
StateChange.MonitoringChange(change.pingStates, consecutivePingFailures.value)
|
||||
))
|
||||
return@collect handleAutoTunnelEvent(
|
||||
autoTunnelStateFlow.value.determineAutoTunnelEvent(
|
||||
StateChange.MonitoringChange(change.pingStates)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,47 +245,64 @@ class AutoTunnelService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAndroidLateCellularActiveChange(
|
||||
previous: NetworkState,
|
||||
new: NetworkState,
|
||||
): Boolean {
|
||||
return (previous.isWifiConnected != new.isWifiConnected &&
|
||||
previous.wifiName == new.wifiName &&
|
||||
previous.isMobileDataConnected != new.isMobileDataConnected)
|
||||
}
|
||||
|
||||
// all relevant settings to auto tunnel
|
||||
private fun areAutoTunnelSettingsTheSame(old: AppSettings, new: AppSettings) : Boolean {
|
||||
return( old.isTunnelOnWifiEnabled == new.isTunnelOnWifiEnabled &&
|
||||
old.isTunnelOnMobileDataEnabled == new.isTunnelOnMobileDataEnabled &&
|
||||
old.isTunnelOnEthernetEnabled == new.isTunnelOnEthernetEnabled &&
|
||||
old.trustedNetworkSSIDs == new.trustedNetworkSSIDs &&
|
||||
old.isPingEnabled == new.isPingEnabled &&
|
||||
old.debounceDelaySeconds == new.debounceDelaySeconds &&
|
||||
old.wifiDetectionMethod == new.wifiDetectionMethod &&
|
||||
old.isVpnKillSwitchEnabled == new.isVpnKillSwitchEnabled &&
|
||||
old.isLanOnKillSwitchEnabled == new.isLanOnKillSwitchEnabled &&
|
||||
old.isDisableKillSwitchOnTrustedEnabled == new.isDisableKillSwitchOnTrustedEnabled &&
|
||||
old.isStopOnNoInternetEnabled == new.isStopOnNoInternetEnabled)
|
||||
private fun areAutoTunnelSettingsTheSame(old: AppSettings, new: AppSettings): Boolean {
|
||||
return (old.isTunnelOnWifiEnabled == new.isTunnelOnWifiEnabled &&
|
||||
old.isTunnelOnMobileDataEnabled == new.isTunnelOnMobileDataEnabled &&
|
||||
old.isTunnelOnEthernetEnabled == new.isTunnelOnEthernetEnabled &&
|
||||
old.trustedNetworkSSIDs == new.trustedNetworkSSIDs &&
|
||||
old.isPingEnabled == new.isPingEnabled &&
|
||||
old.debounceDelaySeconds == new.debounceDelaySeconds &&
|
||||
old.wifiDetectionMethod == new.wifiDetectionMethod &&
|
||||
old.isVpnKillSwitchEnabled == new.isVpnKillSwitchEnabled &&
|
||||
old.isLanOnKillSwitchEnabled == new.isLanOnKillSwitchEnabled &&
|
||||
old.isDisableKillSwitchOnTrustedEnabled == new.isDisableKillSwitchOnTrustedEnabled &&
|
||||
old.isStopOnNoInternetEnabled == new.isStopOnNoInternetEnabled)
|
||||
}
|
||||
|
||||
private fun combineSettings(): Flow<Pair<AppSettings, Tunnels>> {
|
||||
return combine(
|
||||
appDataRepository.get().settings.flow
|
||||
.distinctUntilChanged(::areAutoTunnelSettingsTheSame),
|
||||
appDataRepository.get().tunnels.flow.map { tunnels ->
|
||||
// isActive is ignored for equality checks so user can manually toggle off
|
||||
// tunnel with auto-tunnel
|
||||
tunnels.map { it.copy(isActive = false) }
|
||||
},
|
||||
) { settings, tunnels ->
|
||||
Pair(settings, tunnels)
|
||||
}
|
||||
appDataRepository
|
||||
.get()
|
||||
.settings
|
||||
.flow
|
||||
.distinctUntilChanged(::areAutoTunnelSettingsTheSame),
|
||||
appDataRepository.get().tunnels.flow.map { tunnels ->
|
||||
// isActive is ignored for equality checks so user can manually toggle off
|
||||
// tunnel with auto-tunnel
|
||||
tunnels.map { it.copy(isActive = false) }
|
||||
},
|
||||
) { settings, tunnels ->
|
||||
Pair(settings, tunnels)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun areAutoTunnelPermissionsRequiredTheSame(old: AutoTunnelState, new: AutoTunnelState) : Boolean {
|
||||
private fun areAutoTunnelPermissionsRequiredTheSame(
|
||||
old: AutoTunnelState,
|
||||
new: AutoTunnelState,
|
||||
): Boolean {
|
||||
return (old.settings.wifiDetectionMethod == new.settings.wifiDetectionMethod &&
|
||||
old.networkState.locationPermissionGranted == new.networkState.locationPermissionGranted &&
|
||||
old.networkState.locationServicesEnabled == new.networkState.locationServicesEnabled &&
|
||||
old.tunnels == new.tunnels && old.settings.trustedNetworkSSIDs == new.settings.trustedNetworkSSIDs)
|
||||
old.networkState.locationPermissionGranted ==
|
||||
new.networkState.locationPermissionGranted &&
|
||||
old.networkState.locationServicesEnabled == new.networkState.locationServicesEnabled &&
|
||||
old.tunnels == new.tunnels &&
|
||||
old.settings.trustedNetworkSSIDs == new.settings.trustedNetworkSSIDs)
|
||||
}
|
||||
|
||||
// watch for changes to location permission and notify user it will impact auto-tunneling
|
||||
// TODO or a recheck button for location permission so we dont have to poll it
|
||||
// TODO or a recheck button for location permission so we dont have to poll it
|
||||
private fun startLocationPermissionsNotificationJob(): Job =
|
||||
lifecycleScope.launch(ioDispatcher) {
|
||||
var locationServicesShown = false
|
||||
@@ -270,17 +315,26 @@ class AutoTunnelService : LifecycleService() {
|
||||
val ssidReadRequired: Boolean,
|
||||
)
|
||||
|
||||
autoTunnelStateFlow.distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame)
|
||||
.map { NetworkPermissionState(
|
||||
it.settings.wifiDetectionMethod,
|
||||
it.networkState.locationServicesEnabled == true,
|
||||
it.networkState.locationPermissionGranted == true,
|
||||
(it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } || it.settings.trustedNetworkSSIDs.isNotEmpty()),
|
||||
) }.collect { state ->
|
||||
autoTunnelStateFlow
|
||||
.distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame)
|
||||
.map {
|
||||
NetworkPermissionState(
|
||||
it.settings.wifiDetectionMethod,
|
||||
it.networkState.locationServicesEnabled == true,
|
||||
it.networkState.locationPermissionGranted == true,
|
||||
(it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } ||
|
||||
it.settings.trustedNetworkSSIDs.isNotEmpty()),
|
||||
)
|
||||
}
|
||||
.collect { state ->
|
||||
when (state.detectionMethod) {
|
||||
AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT,
|
||||
AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> {
|
||||
if (!state.locationPermissionsEnabled && !locationPermissionsShown && state.ssidReadRequired) {
|
||||
if (
|
||||
!state.locationPermissionsEnabled &&
|
||||
!locationPermissionsShown &&
|
||||
state.ssidReadRequired
|
||||
) {
|
||||
locationPermissionsShown = true
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
@@ -294,7 +348,11 @@ class AutoTunnelService : LifecycleService() {
|
||||
notification,
|
||||
)
|
||||
}
|
||||
if (!state.locationServicesEnabled && !locationServicesShown && state.ssidReadRequired) {
|
||||
if (
|
||||
!state.locationServicesEnabled &&
|
||||
!locationServicesShown &&
|
||||
state.ssidReadRequired
|
||||
) {
|
||||
locationServicesShown = true
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
@@ -314,8 +372,7 @@ class AutoTunnelService : LifecycleService() {
|
||||
)
|
||||
locationServicesShown = false
|
||||
}
|
||||
if (
|
||||
state.locationPermissionsEnabled || !state.ssidReadRequired) {
|
||||
if (state.locationPermissionsEnabled || !state.ssidReadRequired) {
|
||||
notificationManager.remove(
|
||||
NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID
|
||||
)
|
||||
@@ -327,28 +384,25 @@ class AutoTunnelService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetLastBounceTimes(autoTunnelState: AutoTunnelState) {
|
||||
autoTunnelState.activeTunnels.forEach { (conf, _) ->
|
||||
lastBounceTimes[conf.id] = 0L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAutoTunnelEvent(autoTunnelEvent: AutoTunnelEvent) {
|
||||
autoTunMutex.withLock {
|
||||
when (val event = autoTunnelEvent.also { Timber.i("Auto tunnel event: ${it.javaClass.simpleName}") }) {
|
||||
when (
|
||||
val event =
|
||||
autoTunnelEvent.also {
|
||||
Timber.i("Auto tunnel event: ${it.javaClass.simpleName}")
|
||||
}
|
||||
) {
|
||||
is AutoTunnelEvent.Start ->
|
||||
(event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let {
|
||||
tunnelManager.startTunnel(it)
|
||||
}
|
||||
is AutoTunnelEvent.Stop -> tunnelManager.stopTunnel()
|
||||
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do")
|
||||
is AutoTunnelEvent.Bounce -> handleBounceWithBackoff(event.configsPeerKeyResolvedMap)
|
||||
is AutoTunnelEvent.Bounce ->
|
||||
handleBounceWithBackoff(event.configsPeerKeyResolvedMap)
|
||||
is AutoTunnelEvent.StartKillSwitch -> {
|
||||
Timber.d("Starting kill switch")
|
||||
tunnelManager.setBackendState(
|
||||
BackendState.KILL_SWITCH_ACTIVE,
|
||||
event.allowedIps,
|
||||
)
|
||||
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, event.allowedIps)
|
||||
}
|
||||
AutoTunnelEvent.StopKillSwitch -> {
|
||||
Timber.d("Stopping kill switch")
|
||||
@@ -358,38 +412,51 @@ class AutoTunnelService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleBounceWithBackoff(configsPeerKeyResolvedMapWithFailures: List<Triple<TunnelConf, Map<String, String?>, Int>>) {
|
||||
private suspend fun handleBounceWithBackoff(
|
||||
configsPeerKeyResolvedMap: List<Pair<TunnelConf, Map<String, String?>>>
|
||||
) { // Simplified param: no failureCount
|
||||
val settings = appDataRepository.get().settings.get()
|
||||
val pingIntervalMillis = settings.tunnelPingIntervalSeconds.toMillis()
|
||||
configsPeerKeyResolvedMapWithFailures.forEach { (config, peerMap, failureCount) ->
|
||||
val exponent = (failureCount - CONSECUTIVE_FAILURE_THRESHOLD).coerceAtLeast(0).toDouble()
|
||||
val backoffDelay = (pingIntervalMillis * 2.0.pow(exponent)).toLong().coerceAtMost(MAX_BACKOFF_MS)
|
||||
configsPeerKeyResolvedMap.forEach { (config, peerMap) ->
|
||||
val bounceCount = bounceCounts.value.getOrDefault(config.id, 0)
|
||||
val exponent = bounceCount.toDouble()
|
||||
val backoffDelay =
|
||||
(pingIntervalMillis * 2.0.pow(exponent)).toLong().coerceAtMost(MAX_BACKOFF_MS)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastTime = lastBounceTimes.getOrDefault(config.id, 0L)
|
||||
if (currentTime - lastTime >= backoffDelay) {
|
||||
Timber.d("Bouncing tunnel ${config.name} after $failureCount consecutive failures with calculated backoff delay $backoffDelay ms")
|
||||
Timber.d(
|
||||
"Bouncing tunnel ${config.name} after detecting failure, with bounce count $bounceCount and calculated backoff delay $backoffDelay ms"
|
||||
)
|
||||
tunnelManager.bounceTunnel(config, Ping(peerMap))
|
||||
lastBounceTimes[config.id] = currentTime
|
||||
bounceCounts.update { current ->
|
||||
current.toMutableMap().apply { this[config.id] = (this[config.id] ?: 0) + 1 }
|
||||
}
|
||||
} else {
|
||||
Timber.d("Backoff in progress for tunnel ${config.name}, skipping bounce (required delay: $backoffDelay ms)")
|
||||
Timber.d(
|
||||
"Backoff in progress for tunnel ${config.name}, skipping bounce (required delay: $backoffDelay ms)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
private val debouncedConnectivityStateFlow: Flow<ConnectivityState> by lazy {
|
||||
appDataRepository.get().settings.flow
|
||||
appDataRepository
|
||||
.get()
|
||||
.settings
|
||||
.flow
|
||||
.map { it.debounceDelaySeconds.toMillis() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { debounceMillis ->
|
||||
networkMonitor.connectivityStateFlow
|
||||
.debounce(debounceMillis)
|
||||
networkMonitor.connectivityStateFlow.debounce(debounceMillis)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REEVALUATE_CHECK_DELAY = 5_000L
|
||||
const val CONSECUTIVE_FAILURE_THRESHOLD = 2
|
||||
// try to keep this window short as it will interrupt manual overrides
|
||||
const val REEVALUATE_CHECK_DELAY = 2_000L
|
||||
const val MAX_BACKOFF_MS = 300_000L // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -10,9 +10,11 @@ import org.amnezia.awg.crypto.Key
|
||||
|
||||
sealed class StateChange {
|
||||
data class NetworkChange(val networkState: NetworkState) : StateChange()
|
||||
|
||||
data class SettingsChange(val settings: AppSettings, val tunnels: Tunnels) : StateChange()
|
||||
|
||||
data class ActiveTunnelsChange(val activeTunnels: Map<TunnelConf, TunnelState>) : StateChange()
|
||||
|
||||
data class MonitoringChange(val pingStates: Map<TunnelConf, Map<Key, PingState>?>,
|
||||
val consecutiveFailures: Map<TunnelConf, Int>) : StateChange()
|
||||
}
|
||||
data class MonitoringChange(val pingStates: Map<TunnelConf, Map<Key, PingState>?>) :
|
||||
StateChange()
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,9 +11,9 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoTunnelControlTile : TileService(), LifecycleOwner {
|
||||
|
||||
+1
-1
@@ -17,9 +17,9 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TunnelControlTile : TileService(), LifecycleOwner {
|
||||
|
||||
+1
-1
@@ -9,10 +9,10 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
|
||||
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ShortcutsActivity : ComponentActivity() {
|
||||
|
||||
@@ -61,6 +61,7 @@ abstract class BaseTunnel(
|
||||
status: TunnelStatus?,
|
||||
stats: TunnelStatistics?,
|
||||
pingStates: Map<Key, PingState>?,
|
||||
handshakeSuccessLogs: Boolean?,
|
||||
) {
|
||||
tunStatusMutex.withLock {
|
||||
activeTuns.update { currentTuns ->
|
||||
@@ -71,7 +72,12 @@ abstract class BaseTunnel(
|
||||
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
|
||||
cleanUpTunJob(tunnelConf)
|
||||
currentTuns - originalConf
|
||||
} else if (existingState.status == newStatus && stats == null && pingStates == null) {
|
||||
} else if (
|
||||
existingState.status == newStatus &&
|
||||
stats == null &&
|
||||
pingStates == null &&
|
||||
handshakeSuccessLogs == null
|
||||
) {
|
||||
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newStatus")
|
||||
currentTuns
|
||||
} else {
|
||||
@@ -80,6 +86,8 @@ abstract class BaseTunnel(
|
||||
status = newStatus,
|
||||
statistics = stats ?: existingState.statistics,
|
||||
pingStates = pingStates ?: existingState.pingStates,
|
||||
handshakeSuccessLogs =
|
||||
handshakeSuccessLogs ?: existingState.handshakeSuccessLogs,
|
||||
)
|
||||
currentTuns + (originalConf to updated)
|
||||
}
|
||||
@@ -120,16 +128,20 @@ abstract class BaseTunnel(
|
||||
// For userspace, we need to make sure all previous tunnels are down
|
||||
if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels()
|
||||
tunMutex.withLock {
|
||||
val job = applicationScope.launch {
|
||||
try {
|
||||
Timber.d("Starting tunnel ${tunnelConf.id}...")
|
||||
startTunnelInner(tunnelConf)
|
||||
Timber.d("Started complete for tunnel ${tunnelConf.name}...")
|
||||
// catch cancellation that could occur before and during startTunnelInner and trigger at that suspend point
|
||||
} catch (e: CancellationException) {
|
||||
Timber.w("Tunnel start has been cancelled as ${tunnelConf.name} failed to start")
|
||||
val job =
|
||||
applicationScope.launch {
|
||||
try {
|
||||
Timber.d("Starting tunnel ${tunnelConf.id}...")
|
||||
startTunnelInner(tunnelConf)
|
||||
Timber.d("Started complete for tunnel ${tunnelConf.name}...")
|
||||
// catch cancellation that could occur before and during startTunnelInner
|
||||
// and trigger at that suspend point
|
||||
} catch (e: CancellationException) {
|
||||
Timber.w(
|
||||
"Tunnel start has been cancelled as ${tunnelConf.name} failed to start"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
tunJobs[tunnelConf.id] = job
|
||||
job.invokeOnCompletion {
|
||||
tunJobs.remove(tunnelConf.id)
|
||||
@@ -153,39 +165,53 @@ abstract class BaseTunnel(
|
||||
Timber.d("Started for tun ${currentConf.id}...")
|
||||
saveTunnelActiveState(currentConf, true)
|
||||
serviceManager.startTunnelForegroundService()
|
||||
if(restoreAttempted) _messageEvents.emit(tunnelConf to BackendMessage.BounceRecovery)
|
||||
if(bouncingTunnelIds[currentConf.id] is TunnelStatus.StopReason.Ping) {
|
||||
if (restoreAttempted)
|
||||
_messageEvents.emit(tunnelConf to BackendMessage.BounceRecovery)
|
||||
if (bouncingTunnelIds[currentConf.id] is TunnelStatus.StopReason.Ping) {
|
||||
_messageEvents.emit(tunnelConf to BackendMessage.BounceSuccess)
|
||||
}
|
||||
return // Success, return
|
||||
return // Success, return
|
||||
} catch (e: BackendError) {
|
||||
originalError = originalError ?: e
|
||||
val bounceReason = bouncingTunnelIds[currentConf.id]
|
||||
if (!restoreAttempted && bounceReason is TunnelStatus.StopReason.Ping) {
|
||||
Timber.i("Attempting to recover bounce failure with previously resolved endpoints for ${currentConf.name}")
|
||||
Timber.i(
|
||||
"Attempting to recover bounce failure with previously resolved endpoints for ${currentConf.name}"
|
||||
)
|
||||
try {
|
||||
val previouslyResolved = bounceReason.previouslyResolvedEndpoints
|
||||
val configProxy = ConfigProxy.from(currentConf.toAmConfig())
|
||||
val updatedConfigProxy = configProxy.copy(
|
||||
peers = configProxy.peers.map {
|
||||
it.copy(endpoint = previouslyResolved[it.publicKey] ?: it.endpoint)
|
||||
}
|
||||
)
|
||||
val updatedConfigProxy =
|
||||
configProxy.copy(
|
||||
peers =
|
||||
configProxy.peers.map {
|
||||
it.copy(
|
||||
endpoint =
|
||||
previouslyResolved[it.publicKey] ?: it.endpoint
|
||||
)
|
||||
}
|
||||
)
|
||||
val (wg, amnezia) = updatedConfigProxy.buildConfigs()
|
||||
currentConf = currentConf.copyWithCallback(
|
||||
amQuick = amnezia.toAwgQuickString(true),
|
||||
wgQuick = wg.toWgQuickString(true)
|
||||
)
|
||||
currentConf =
|
||||
currentConf.copyWithCallback(
|
||||
amQuick = amnezia.toAwgQuickString(true),
|
||||
wgQuick = wg.toWgQuickString(true),
|
||||
)
|
||||
bouncingTunnelIds.remove(currentConf.id)
|
||||
restoreAttempted = true
|
||||
continue // Retry
|
||||
continue // Retry
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to update config with resolved endpoints for ${currentConf.name}")
|
||||
// Fall through to failure (will emit BounceFailed since retryAttempted=true)
|
||||
Timber.e(
|
||||
e,
|
||||
"Failed to update config with resolved endpoints for ${currentConf.name}",
|
||||
)
|
||||
// Fall through to failure (will emit BounceFailed since
|
||||
// retryAttempted=true)
|
||||
}
|
||||
}
|
||||
Timber.e(e, "Failed to start backend for ${currentConf.name}")
|
||||
val emitError = if (restoreAttempted) BackendError.BounceFailed(originalError) else e
|
||||
val emitError =
|
||||
if (restoreAttempted) BackendError.BounceFailed(originalError) else e
|
||||
_errorEvents.emit(currentConf to emitError)
|
||||
updateTunnelStatus(currentConf, TunnelStatus.Down)
|
||||
return
|
||||
@@ -222,8 +248,7 @@ abstract class BaseTunnel(
|
||||
}
|
||||
|
||||
private fun handleServiceStateOnChange() {
|
||||
if (activeTuns.value.isEmpty() && bouncingTunnelIds.isEmpty())
|
||||
serviceManager.stopTunnelForegroundService()
|
||||
if (activeTuns.value.isEmpty()) serviceManager.stopTunnelForegroundService()
|
||||
}
|
||||
|
||||
private suspend fun handleStuckStartingTunnelShutdown(tunnel: TunnelConf) {
|
||||
@@ -266,4 +291,4 @@ abstract class BaseTunnel(
|
||||
companion object {
|
||||
const val BOUNCE_DELAY = 300L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ constructor(
|
||||
updateTunnelStatus(tunnel, TunnelStatus.Starting)
|
||||
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
|
||||
} catch (e: BackendException) {
|
||||
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
|
||||
throw e.toBackendError()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
|
||||
throw BackendError.Config
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-84
@@ -1,9 +1,5 @@
|
||||
package com.zaneschepke.wireguardautotunnel.core.tunnel
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
|
||||
@@ -13,9 +9,11 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.plus
|
||||
import org.amnezia.awg.crypto.Key
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
@@ -27,9 +25,8 @@ constructor(
|
||||
private val kernelTunnel: TunnelProvider,
|
||||
private val userspaceTunnel: TunnelProvider,
|
||||
private val appDataRepository: AppDataRepository,
|
||||
private val applicationScope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
private val notificationManager: NotificationManager,
|
||||
applicationScope: CoroutineScope,
|
||||
ioDispatcher: CoroutineDispatcher,
|
||||
) : TunnelProvider {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -45,82 +42,30 @@ constructor(
|
||||
initialValue = userspaceTunnel,
|
||||
)
|
||||
|
||||
override val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>> = tunnelProviderFlow.value.activeTunnels
|
||||
override val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>> =
|
||||
tunnelProviderFlow.value.activeTunnels
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>> =
|
||||
combine(
|
||||
tunnelProviderFlow.flatMapLatest { it.errorEvents },
|
||||
WireGuardAutoTunnel.uiActive,
|
||||
) { errorEvent, isEnabled ->
|
||||
if (isEnabled) errorEvent else null
|
||||
}
|
||||
.filterNotNull()
|
||||
tunnelProviderFlow
|
||||
.flatMapLatest { it.errorEvents }
|
||||
.shareIn(
|
||||
scope = applicationScope.plus(ioDispatcher),
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
started = SharingStarted.Eagerly,
|
||||
replay = 0,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override val messageEvents: SharedFlow<Pair<TunnelConf, BackendMessage>> =
|
||||
combine(
|
||||
tunnelProviderFlow.flatMapLatest { it.messageEvents },
|
||||
WireGuardAutoTunnel.uiActive,
|
||||
) { messageEvent, isEnabled ->
|
||||
if (isEnabled) messageEvent else null
|
||||
}
|
||||
tunnelProviderFlow
|
||||
.flatMapLatest { it.messageEvents }
|
||||
.filterNotNull()
|
||||
.shareIn(
|
||||
scope = applicationScope.plus(ioDispatcher),
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
started = SharingStarted.Eagerly,
|
||||
replay = 0,
|
||||
)
|
||||
|
||||
// observe tunnel errors and messages and launch notifications if ui is inactive
|
||||
init {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
launch {
|
||||
errorEvents.collect { (tunnelConf, error) ->
|
||||
if (!WireGuardAutoTunnel.uiActive.value) {
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
WireGuardNotification.NotificationChannels.VPN,
|
||||
title = StringValue.DynamicString(tunnelConf.name),
|
||||
description = when(error) {
|
||||
is BackendError.BounceFailed -> error.toStringValue()
|
||||
else -> StringValue.StringResource(
|
||||
R.string.tunnel_error_template,
|
||||
error.toStringRes(),
|
||||
)
|
||||
}
|
||||
)
|
||||
notificationManager.show(
|
||||
NotificationManager.TUNNEL_ERROR_NOTIFICATION_ID,
|
||||
notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
messageEvents.collect { (tunnelConf, message) ->
|
||||
if (!WireGuardAutoTunnel.uiActive.value) {
|
||||
val notification =
|
||||
notificationManager.createNotification(
|
||||
WireGuardNotification.NotificationChannels.VPN,
|
||||
title = StringValue.DynamicString(tunnelConf.name),
|
||||
description = message.toStringValue(),
|
||||
)
|
||||
notificationManager.show(
|
||||
NotificationManager.TUNNEL_MESSAGES_NOTIFICATION_ID,
|
||||
notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val bouncingTunnelIds: ConcurrentHashMap<Int, TunnelStatus.StopReason> =
|
||||
tunnelProviderFlow.value.bouncingTunnelIds
|
||||
|
||||
@@ -161,24 +106,30 @@ constructor(
|
||||
status: TunnelStatus?,
|
||||
stats: TunnelStatistics?,
|
||||
pingStates: Map<Key, PingState>?,
|
||||
handshakeSuccessLogs: Boolean?,
|
||||
) {
|
||||
tunnelProviderFlow.value.updateTunnelStatus(tunnelConf, status, stats, pingStates)
|
||||
tunnelProviderFlow.value.updateTunnelStatus(
|
||||
tunnelConf,
|
||||
status,
|
||||
stats,
|
||||
pingStates,
|
||||
handshakeSuccessLogs,
|
||||
)
|
||||
}
|
||||
|
||||
fun restorePreviousState(): Job =
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
val settings = appDataRepository.settings.get()
|
||||
if (settings.isRestoreOnBootEnabled) {
|
||||
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
|
||||
val tunsToStart =
|
||||
previouslyActiveTuns.filterNot { tun ->
|
||||
activeTunnels.value.any { tun.id == it.key.id }
|
||||
}
|
||||
if (settings.isKernelEnabled) {
|
||||
return@launch tunsToStart.forEach { startTunnel(it) }
|
||||
} else {
|
||||
tunsToStart.firstOrNull()?.let { startTunnel(it) }
|
||||
suspend fun restorePreviousState() {
|
||||
val settings = appDataRepository.settings.get()
|
||||
if (settings.isRestoreOnBootEnabled) {
|
||||
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
|
||||
val tunsToStart =
|
||||
previouslyActiveTuns.filterNot { tun ->
|
||||
activeTunnels.value.any { tun.id == it.key.id }
|
||||
}
|
||||
if (settings.isKernelEnabled) {
|
||||
return tunsToStart.forEach { startTunnel(it) }
|
||||
} else {
|
||||
tunsToStart.firstOrNull()?.let { startTunnel(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+160
-153
@@ -10,6 +10,7 @@ import com.zaneschepke.wireguardautotunnel.domain.state.PingState
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis
|
||||
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
|
||||
import dagger.hilt.android.scopes.ServiceScoped
|
||||
import io.ktor.util.collections.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.amnezia.awg.crypto.Key
|
||||
@@ -17,7 +18,9 @@ import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@ServiceScoped
|
||||
class TunnelMonitor @Inject constructor(
|
||||
class TunnelMonitor
|
||||
@Inject
|
||||
constructor(
|
||||
private val appDataRepository: AppDataRepository,
|
||||
private val tunnelManager: TunnelManager,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
@@ -25,26 +28,16 @@ class TunnelMonitor @Inject constructor(
|
||||
private val logReader: LogReader,
|
||||
) {
|
||||
|
||||
private val tunnelJobs = mutableMapOf<TunnelConf, Job>()
|
||||
|
||||
private val pingStatsFlows = mutableMapOf<TunnelConf, MutableStateFlow<Map<Key, PingState>>>()
|
||||
|
||||
fun startMonitoring(callerScope: CoroutineScope, tunnelConf: TunnelConf) {
|
||||
if (tunnelJobs.containsKey(tunnelConf)) return
|
||||
pingStatsFlows[tunnelConf] = MutableStateFlow(emptyMap())
|
||||
tunnelJobs[tunnelConf] = callerScope.launch {
|
||||
@OptIn(FlowPreview::class)
|
||||
suspend fun startMonitoring(tunnelConf: TunnelConf, withLogs: Boolean): Job = coroutineScope {
|
||||
launch {
|
||||
launch { startTunnelConfChangesJob(tunnelConf) }
|
||||
launch { startPingMonitor(tunnelConf) }
|
||||
launch { startWgStatsPoll(tunnelConf) } // Add WG stats polling
|
||||
launch { startLogsMonitor() } // Global, could be started once if needed
|
||||
launch { startWgStatsPoll(tunnelConf) }
|
||||
if (withLogs) launch { startLogsMonitor(tunnelConf) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopMonitoring(tunnelConf: TunnelConf) {
|
||||
tunnelJobs.remove(tunnelConf)?.cancel()
|
||||
pingStatsFlows.remove(tunnelConf)
|
||||
}
|
||||
|
||||
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
|
||||
appDataRepository.tunnels.flow
|
||||
.map { storedTunnels -> storedTunnels.firstOrNull { it.id == tunnelConf.id } }
|
||||
@@ -63,14 +56,29 @@ class TunnelMonitor @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startLogsMonitor() {
|
||||
logReader.liveLogs.collect { message ->
|
||||
// TODO monitor logs for handshake failures for additional monitoring
|
||||
private suspend fun startLogsMonitor(tunnelConf: TunnelConf) {
|
||||
logReader.liveLogs.collect { log ->
|
||||
val healthLogs =
|
||||
when {
|
||||
log.message.contains(HANDSHAKE_RESPONSE_TEXT, true) ||
|
||||
log.message.contains(KEEPALIVE_RESPONSE_TEXT, true) -> true
|
||||
log.message.contains(HANDSHAKE_INIT_FAILED_TEXT, true) ||
|
||||
log.message.contains(HANDSHAKE_NOT_COMPLETED_TEXT) ||
|
||||
log.message.contains(DATA_PACKET_FAILED_TEXT) -> false
|
||||
|
||||
else -> null
|
||||
}
|
||||
healthLogs?.let { healthy ->
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, null, healthy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startPingMonitor(tunnelConf: TunnelConf) = coroutineScope {
|
||||
val tunStateFlow = tunnelManager.activeTunnels.mapNotNull { it.getValueById(tunnelConf.id) }.stateIn(this)
|
||||
val pingStatsFlow = MutableStateFlow<Map<Key, PingState>>(emptyMap())
|
||||
|
||||
val tunStateFlow =
|
||||
tunnelManager.activeTunnels.mapNotNull { it.getValueById(tunnelConf.id) }.stateIn(this)
|
||||
|
||||
val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this)
|
||||
|
||||
@@ -80,160 +88,158 @@ class TunnelMonitor @Inject constructor(
|
||||
val ethernetConnected: Boolean,
|
||||
val wifiConnected: Boolean,
|
||||
val cellularConnected: Boolean,
|
||||
val wifiSsid: String?
|
||||
val wifiSsid: String?,
|
||||
)
|
||||
|
||||
val networkChangeFlow = connectivityStateFlow.map {
|
||||
NetworkChangeKey(
|
||||
ethernetConnected = it.ethernetConnected,
|
||||
wifiConnected = it.wifiState.connected,
|
||||
cellularConnected = it.cellularConnected,
|
||||
wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null
|
||||
)
|
||||
}.distinctUntilChanged().stateIn(this)
|
||||
connectivityStateFlow
|
||||
.map {
|
||||
NetworkChangeKey(
|
||||
ethernetConnected = it.ethernetConnected,
|
||||
wifiConnected = it.wifiState.connected,
|
||||
cellularConnected = it.cellularConnected,
|
||||
wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(this)
|
||||
|
||||
appDataRepository.settings.flow.distinctUntilChanged { old, new ->
|
||||
old.isPingEnabled == new.isPingEnabled &&
|
||||
appDataRepository.settings.flow
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.isPingEnabled == new.isPingEnabled &&
|
||||
old.tunnelPingIntervalSeconds == new.tunnelPingIntervalSeconds &&
|
||||
old.tunnelPingAttempts == new.tunnelPingAttempts &&
|
||||
old.tunnelPingTimeoutSeconds == new.tunnelPingTimeoutSeconds
|
||||
}.collectLatest { settings ->
|
||||
}
|
||||
.collectLatest { settings ->
|
||||
if (!settings.isPingEnabled) return@collectLatest
|
||||
|
||||
if(!settings.isPingEnabled) return@collectLatest
|
||||
Timber.d("Starting pinger for ${tunnelConf.tunName} with settings")
|
||||
|
||||
Timber.d("Starting pinger for ${tunnelConf.tunName} with settings")
|
||||
val config = tunnelConf.toAmConfig()
|
||||
|
||||
val config = tunnelConf.toAmConfig()
|
||||
val pingablePeers = config.peers.filter { it.allowedIps.isNotEmpty() }
|
||||
if (pingablePeers.isEmpty()) return@collectLatest
|
||||
|
||||
val pingablePeers = config.peers.filter { it.allowedIps.isNotEmpty() }
|
||||
if (pingablePeers.isEmpty()) return@collectLatest
|
||||
suspend fun performPing() {
|
||||
val updates = ConcurrentMap<Key, PingState>()
|
||||
|
||||
val pingStatsFlow = pingStatsFlows[tunnelConf] ?: return@collectLatest
|
||||
pingablePeers.forEach { peer ->
|
||||
val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState()
|
||||
|
||||
// Initialize
|
||||
pingStatsFlow.value = emptyMap()
|
||||
|
||||
suspend fun performPing() {
|
||||
delay(3_000L)
|
||||
val updates = mutableMapOf<Key, PingState>()
|
||||
|
||||
pingablePeers.forEach { peer ->
|
||||
val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState()
|
||||
|
||||
val allowedIpStr = peer.allowedIps.firstOrNull()?.toString()
|
||||
if (allowedIpStr == null) {
|
||||
updates[peer.publicKey] = previousState.copy(
|
||||
isReachable = false,
|
||||
failureReason = FailureReason.NoResolvedEndpoint,
|
||||
lastPingAttemptMillis = System.currentTimeMillis()
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val host = {
|
||||
val parts = allowedIpStr.split("/")
|
||||
val internalIp = if (parts.size == 2) parts[0] else allowedIpStr
|
||||
|
||||
val prefix = if (parts.size == 2) parts[1].toIntOrNull() ?: 32 else 32
|
||||
if (prefix <= 1) {
|
||||
tunnelConf.pingTarget ?: CLOUDFLARE_IPV4_IP
|
||||
} else {
|
||||
internalIp.removeSurrounding("[", "]")
|
||||
val allowedIpStr = peer.allowedIps.firstOrNull()?.toString()
|
||||
if (allowedIpStr == null) {
|
||||
updates[peer.publicKey] =
|
||||
previousState.copy(
|
||||
isReachable = false,
|
||||
failureReason = FailureReason.NoResolvedEndpoint,
|
||||
lastPingAttemptMillis = System.currentTimeMillis(),
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
}.invoke()
|
||||
|
||||
val attemptTime = System.currentTimeMillis()
|
||||
runCatching {
|
||||
val pingStats = settings.tunnelPingTimeoutSeconds?.let {
|
||||
networkUtils.pingWithStats(host, settings.tunnelPingAttempts, it.toMillis())
|
||||
} ?: networkUtils.pingWithStats(host, settings.tunnelPingAttempts)
|
||||
val host =
|
||||
{
|
||||
val parts = allowedIpStr.split("/")
|
||||
val internalIp = if (parts.size == 2) parts[0] else allowedIpStr
|
||||
|
||||
updates[peer.publicKey] = previousState.copy(
|
||||
transmitted = pingStats.transmitted,
|
||||
received = pingStats.received,
|
||||
packetLoss = pingStats.packetLoss,
|
||||
rttMin = pingStats.rttMin,
|
||||
rttMax = pingStats.rttMax,
|
||||
rttAvg = pingStats.rttAvg,
|
||||
rttStddev = pingStats.rttStddev,
|
||||
isReachable = pingStats.isReachable,
|
||||
failureReason = if (pingStats.isReachable) null else FailureReason.PingFailed,
|
||||
lastSuccessfulPingMillis = pingStats.lastSuccessfulPingMillis ?: previousState.lastSuccessfulPingMillis,
|
||||
pingTarget = host,
|
||||
lastPingAttemptMillis = attemptTime
|
||||
)
|
||||
Timber.d(
|
||||
"Ping successful for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats"
|
||||
)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Ping failed for peer ${peer.publicKey} in ${tunnelConf.tunName} to host $host")
|
||||
updates[peer.publicKey] = previousState.copy(
|
||||
isReachable = false,
|
||||
failureReason = FailureReason.PingFailed,
|
||||
pingTarget = host,
|
||||
lastPingAttemptMillis = attemptTime
|
||||
)
|
||||
val prefix =
|
||||
if (parts.size == 2) parts[1].toIntOrNull() ?: 32 else 32
|
||||
if (prefix <= 1) {
|
||||
tunnelConf.pingTarget ?: CLOUDFLARE_IPV4_IP
|
||||
} else {
|
||||
internalIp.removeSurrounding("[", "]")
|
||||
}
|
||||
}
|
||||
.invoke()
|
||||
|
||||
val attemptTime = System.currentTimeMillis()
|
||||
runCatching {
|
||||
val pingStats =
|
||||
settings.tunnelPingTimeoutSeconds?.let {
|
||||
networkUtils.pingWithStats(
|
||||
host,
|
||||
settings.tunnelPingAttempts,
|
||||
it.toMillis(),
|
||||
)
|
||||
}
|
||||
?: networkUtils.pingWithStats(
|
||||
host,
|
||||
settings.tunnelPingAttempts,
|
||||
)
|
||||
|
||||
updates[peer.publicKey] =
|
||||
previousState.copy(
|
||||
transmitted = pingStats.transmitted,
|
||||
received = pingStats.received,
|
||||
packetLoss = pingStats.packetLoss,
|
||||
rttMin = pingStats.rttMin,
|
||||
rttMax = pingStats.rttMax,
|
||||
rttAvg = pingStats.rttAvg,
|
||||
rttStddev = pingStats.rttStddev,
|
||||
isReachable = pingStats.isReachable,
|
||||
failureReason =
|
||||
if (pingStats.isReachable) null
|
||||
else FailureReason.PingFailed,
|
||||
lastSuccessfulPingMillis =
|
||||
pingStats.lastSuccessfulPingMillis
|
||||
?: previousState.lastSuccessfulPingMillis,
|
||||
pingTarget = host,
|
||||
lastPingAttemptMillis = attemptTime,
|
||||
)
|
||||
Timber.d(
|
||||
"Ping completed for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats"
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(
|
||||
it,
|
||||
"Ping failed for peer ${peer.publicKey} in ${tunnelConf.tunName} to host $host",
|
||||
)
|
||||
updates[peer.publicKey] =
|
||||
previousState.copy(
|
||||
isReachable = false,
|
||||
failureReason = FailureReason.PingFailed,
|
||||
pingTarget = host,
|
||||
lastPingAttemptMillis = attemptTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.isNotEmpty()) {
|
||||
pingStatsFlow.update { updates }
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, updates)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.isNotEmpty()) {
|
||||
pingStatsFlow.update { current ->
|
||||
val newMap = current.toMutableMap()
|
||||
newMap.putAll(updates)
|
||||
newMap
|
||||
}
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, updates)
|
||||
}
|
||||
}
|
||||
// Wait for the tunnel to be fully active
|
||||
tunStateFlow.filter { state -> state.status == TunnelStatus.Up }.first()
|
||||
|
||||
// Wait for the tunnel to be fully active
|
||||
tunStateFlow.filter { state ->
|
||||
state.status == TunnelStatus.Up
|
||||
}.first()
|
||||
// small delay to make sure tunnel is fully up before we actively monitor
|
||||
delay(3_000L)
|
||||
|
||||
// Handle initial network state upon starting/restarting the collector
|
||||
val initialConnected = isNetworkConnected.value
|
||||
if (!initialConnected) {
|
||||
Timber.d("Initial no network connectivity for ${tunnelConf.tunName}")
|
||||
pingStatsFlow.update { current ->
|
||||
current.mapValues { entry -> entry.value.copy(isReachable = false, failureReason = FailureReason.NoConnectivity, lastPingAttemptMillis = System.currentTimeMillis()) }
|
||||
}
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, pingStatsFlow.value)
|
||||
} else {
|
||||
performPing()
|
||||
}
|
||||
|
||||
// Launch a separate coroutine to monitor network changes
|
||||
launch {
|
||||
// Drop the first emission since initial state is already handled above
|
||||
networkChangeFlow.drop(1).collect { _ ->
|
||||
val connected = isNetworkConnected.value
|
||||
if (connected) {
|
||||
Timber.d("Network change detected for ${tunnelConf.tunName}, triggering immediate ping")
|
||||
while (isActive) {
|
||||
if (isNetworkConnected.value) {
|
||||
performPing()
|
||||
} else {
|
||||
Timber.d("Network connectivity lost for ${tunnelConf.tunName}")
|
||||
pingStatsFlow.update { current ->
|
||||
current.mapValues { entry -> entry.value.copy(isReachable = false, failureReason = FailureReason.NoConnectivity, lastPingAttemptMillis = System.currentTimeMillis()) }
|
||||
current.mapValues { entry ->
|
||||
entry.value.copy(
|
||||
isReachable = false,
|
||||
failureReason = FailureReason.NoConnectivity,
|
||||
lastPingAttemptMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, pingStatsFlow.value)
|
||||
tunnelManager.updateTunnelStatus(
|
||||
tunnelConf,
|
||||
null,
|
||||
null,
|
||||
pingStatsFlow.value,
|
||||
)
|
||||
}
|
||||
delay(settings.tunnelPingIntervalSeconds.toMillis())
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop for scheduled pings, running independently of network changes
|
||||
while (isActive) {
|
||||
delay(settings.tunnelPingIntervalSeconds.toMillis())
|
||||
if (isNetworkConnected.value) {
|
||||
performPing()
|
||||
} else {
|
||||
pingStatsFlow.update { current ->
|
||||
current.mapValues { entry -> entry.value.copy(isReachable = false, failureReason = FailureReason.NoConnectivity, lastPingAttemptMillis = System.currentTimeMillis()) }
|
||||
}
|
||||
tunnelManager.updateTunnelStatus(tunnelConf, null, null, pingStatsFlow.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startWgStatsPoll(tunnelConf: TunnelConf) = coroutineScope {
|
||||
@@ -250,10 +256,11 @@ class TunnelMonitor @Inject constructor(
|
||||
|
||||
const val STATS_DELAY = 1_000L
|
||||
|
||||
// ipv6 disabled or block on network
|
||||
// Failed to send handshake initiation: write udp [::]
|
||||
// Failed to send data packets: write udp [::]
|
||||
// Failed to send data packets: write udp 0.0.0.0:51820
|
||||
// Handshake did not complete after 5 seconds, retrying
|
||||
const val KEEPALIVE_RESPONSE_TEXT = "Receiving keepalive packet"
|
||||
const val HANDSHAKE_RESPONSE_TEXT = "Received handshake response"
|
||||
const val HANDSHAKE_INIT_FAILED_TEXT = "Failed to send handshake initiation: write udp"
|
||||
const val DATA_PACKET_FAILED_TEXT = "Failed to send data packets"
|
||||
const val HANDSHAKE_NOT_COMPLETED_TEXT =
|
||||
"Handshake did not complete after 5 seconds, retrying"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,12 @@ interface TunnelProvider {
|
||||
val bouncingTunnelIds: ConcurrentHashMap<Int, TunnelStatus.StopReason>
|
||||
|
||||
fun hasVpnPermission(): Boolean
|
||||
|
||||
suspend fun updateTunnelStatus(
|
||||
tunnelConf: TunnelConf,
|
||||
status: TunnelStatus? = null,
|
||||
stats: TunnelStatistics? = null,
|
||||
pingStates: Map<Key, PingState>? = null,
|
||||
handshakeSuccessLogs: Boolean? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
|
||||
@@ -39,6 +40,9 @@ constructor(
|
||||
} catch (e: BackendException) {
|
||||
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
|
||||
throw e.toBackendError()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
|
||||
throw BackendError.Config
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,17 @@ package com.zaneschepke.wireguardautotunnel.core.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.*
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@HiltWorker
|
||||
class ServiceWorker
|
||||
|
||||
@@ -51,28 +51,20 @@ class RemoveTunnelPauseMigration : AutoMigrationSpec
|
||||
class WifiDetectionMigration : AutoMigrationSpec
|
||||
|
||||
@DeleteColumn.Entries(
|
||||
DeleteColumn(
|
||||
tableName = "TunnelConfig",
|
||||
columnName = "ping_interval"
|
||||
),
|
||||
DeleteColumn(
|
||||
tableName = "TunnelConfig",
|
||||
columnName = "ping_cooldown"
|
||||
),
|
||||
DeleteColumn(
|
||||
tableName = "Settings",
|
||||
columnName = "split_tunnel_apps"
|
||||
)
|
||||
DeleteColumn(tableName = "TunnelConfig", columnName = "ping_interval"),
|
||||
DeleteColumn(tableName = "TunnelConfig", columnName = "ping_cooldown"),
|
||||
DeleteColumn(tableName = "Settings", columnName = "split_tunnel_apps"),
|
||||
)
|
||||
@RenameColumn.Entries(
|
||||
RenameColumn(
|
||||
tableName = "TunnelConfig",
|
||||
fromColumnName = "is_ping_enabled",
|
||||
toColumnName = "restart_on_ping_failure"
|
||||
toColumnName = "restart_on_ping_failure",
|
||||
),
|
||||
RenameColumn(
|
||||
tableName = "TunnelConfig",
|
||||
fromColumnName = "ping_ip",
|
||||
toColumnName = "ping_target",
|
||||
),
|
||||
RenameColumn(
|
||||
tableName = "TunnelConfig",
|
||||
fromColumnName = "ping_ip",
|
||||
toColumnName = "ping_target")
|
||||
)
|
||||
class PingMigration : AutoMigrationSpec
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.*
|
||||
import com.zaneschepke.wireguardautotunnel.data.entity.Settings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.*
|
||||
import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -10,8 +10,7 @@ data class Settings(
|
||||
@ColumnInfo(name = "is_tunnel_enabled") val isAutoTunnelEnabled: Boolean = false,
|
||||
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
|
||||
val isTunnelOnMobileDataEnabled: Boolean = false,
|
||||
@ColumnInfo(name = "trusted_network_ssids")
|
||||
val trustedNetworkSSIDs: List<String> = emptyList(),
|
||||
@ColumnInfo(name = "trusted_network_ssids") val trustedNetworkSSIDs: List<String> = emptyList(),
|
||||
@ColumnInfo(name = "is_always_on_vpn_enabled") val isAlwaysOnVpnEnabled: Boolean = false,
|
||||
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
|
||||
val isTunnelOnEthernetEnabled: Boolean = false,
|
||||
@@ -51,10 +50,8 @@ data class Settings(
|
||||
val isPingMonitoringEnabled: Boolean = true,
|
||||
@ColumnInfo(name = "tunnel_ping_interval_sec", defaultValue = "30")
|
||||
val tunnelPingIntervalSeconds: Int = 30,
|
||||
@ColumnInfo(name = "tunnel_ping_attempts", defaultValue = "3")
|
||||
val tunnelPingAttempts: Int = 3,
|
||||
@ColumnInfo(name = "tunnel_ping_timeout_sec")
|
||||
val tunnelPingTimeoutSeconds: Int? = null,
|
||||
@ColumnInfo(name = "tunnel_ping_attempts", defaultValue = "3") val tunnelPingAttempts: Int = 3,
|
||||
@ColumnInfo(name = "tunnel_ping_timeout_sec") val tunnelPingTimeoutSeconds: Int? = null,
|
||||
) {
|
||||
enum class WifiDetectionMethod(val value: Int) {
|
||||
DEFAULT(0),
|
||||
|
||||
@@ -33,4 +33,4 @@ data class TunnelConfig(
|
||||
companion object {
|
||||
const val AM_QUICK_DEFAULT = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.data.mapper
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate
|
||||
import kotlin.collections.firstOrNull
|
||||
|
||||
object GitHubReleaseMapper {
|
||||
fun toAppUpdate(gitHubRelease: GitHubRelease, newVersion: String): AppUpdate {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.network
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object KtorClient {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.zaneschepke.wireguardautotunnel.data.network
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class KtorGitHubApi(private val client: HttpClient) : GitHubApi {
|
||||
override suspend fun getLatestRelease(owner: String, repo: String): Result<GitHubRelease> {
|
||||
|
||||
+5
-3
@@ -124,7 +124,8 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
|
||||
}
|
||||
|
||||
override suspend fun getShowDetailedPing(): Boolean {
|
||||
return dataStoreManager.getFromStore(DataStoreManager.showDetailedPingStats) ?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT
|
||||
return dataStoreManager.getFromStore(DataStoreManager.showDetailedPingStats)
|
||||
?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT
|
||||
}
|
||||
|
||||
override val flow: Flow<AppState> =
|
||||
@@ -152,8 +153,9 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
|
||||
isRemoteControlEnabled =
|
||||
pref[DataStoreManager.isRemoteControlEnabled]
|
||||
?: GeneralState.IS_REMOTE_CONTROL_ENABLED,
|
||||
showDetailedPingStats = pref[DataStoreManager.showDetailedPingStats] ?:
|
||||
GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT,
|
||||
showDetailedPingStats =
|
||||
pref[DataStoreManager.showDetailedPingStats]
|
||||
?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT,
|
||||
remoteKey = pref[DataStoreManager.remoteKey],
|
||||
locale = pref[DataStoreManager.locale],
|
||||
theme = getTheme(),
|
||||
|
||||
+6
-8
@@ -9,17 +9,15 @@ import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import java.io.File
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
class GitHubUpdateRepository(
|
||||
private val gitHubApi: GitHubApi,
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.content.Context
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.LogcatReader
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
|
||||
import com.zaneschepke.wireguardautotunnel.core.shortcut.DynamicShortcutManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
|
||||
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
|
||||
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -54,4 +56,13 @@ class AppModule {
|
||||
fun provideNetworkUtils(@IoDispatcher ioDispatcher: CoroutineDispatcher): NetworkUtils {
|
||||
return NetworkUtils(ioDispatcher)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideNotificationMonitor(
|
||||
tunnelManager: TunnelManager,
|
||||
notificationManager: NotificationManager,
|
||||
): NotificationMonitor {
|
||||
return NotificationMonitor(tunnelManager, notificationManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,16 @@ import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
|
||||
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
|
||||
import com.zaneschepke.wireguardautotunnel.data.network.KtorClient
|
||||
import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.GitHubUpdateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.data.repository.*
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.*
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import io.ktor.client.HttpClient
|
||||
import javax.inject.Singleton
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
||||
@@ -107,7 +107,6 @@ class TunnelModule {
|
||||
appDataRepository,
|
||||
applicationScope,
|
||||
ioDispatcher,
|
||||
notificationManager,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ sealed class TunnelStatus {
|
||||
|
||||
sealed class StopReason {
|
||||
data object User : StopReason()
|
||||
data class Ping(val previouslyResolvedEndpoints: Map<String,String?>) : StopReason()
|
||||
|
||||
data class Ping(val previouslyResolvedEndpoints: Map<String, String?>) : StopReason()
|
||||
|
||||
data object ConfigChanged : StopReason()
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -5,12 +5,14 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
sealed class AutoTunnelEvent {
|
||||
data class Start(val tunnelConf: TunnelConf? = null) : AutoTunnelEvent()
|
||||
|
||||
data class Bounce(val configsPeerKeyResolvedMap: List<Triple<TunnelConf, Map<String, String?>, Int>>) : AutoTunnelEvent()
|
||||
data class Bounce(val configsPeerKeyResolvedMap: List<Pair<TunnelConf, Map<String, String?>>>) :
|
||||
AutoTunnelEvent()
|
||||
|
||||
data object Stop : AutoTunnelEvent()
|
||||
|
||||
data object DoNothing : AutoTunnelEvent()
|
||||
|
||||
data class StartKillSwitch(val allowedIps : List<String>) : AutoTunnelEvent()
|
||||
data class StartKillSwitch(val allowedIps: List<String>) : AutoTunnelEvent()
|
||||
|
||||
data object StopKillSwitch : AutoTunnelEvent()
|
||||
}
|
||||
|
||||
+19
-14
@@ -22,22 +22,27 @@ sealed class BackendError : Exception() {
|
||||
|
||||
data class BounceFailed(val error: BackendError) : BackendError()
|
||||
|
||||
fun toStringRes() = when (this) {
|
||||
Config -> R.string.config_error
|
||||
DNS -> R.string.dns_resolve_error
|
||||
KernelModuleName -> R.string.kernel_name_error
|
||||
NotAuthorized,
|
||||
Unauthorized -> R.string.auth_error
|
||||
ServiceNotRunning -> R.string.service_running_error
|
||||
Unknown -> R.string.unknown_error
|
||||
TunnelNameTooLong -> R.string.error_tunnel_name
|
||||
is BounceFailed -> R.string.bounce_failed_template
|
||||
}
|
||||
fun toStringRes() =
|
||||
when (this) {
|
||||
Config -> R.string.config_error
|
||||
DNS -> R.string.dns_resolve_error
|
||||
KernelModuleName -> R.string.kernel_name_error
|
||||
NotAuthorized,
|
||||
Unauthorized -> R.string.auth_error
|
||||
ServiceNotRunning -> R.string.service_running_error
|
||||
Unknown -> R.string.unknown_error
|
||||
TunnelNameTooLong -> R.string.error_tunnel_name
|
||||
is BounceFailed -> R.string.bounce_failed_template
|
||||
}
|
||||
|
||||
fun toStringValue() : StringValue {
|
||||
fun toStringValue(): StringValue {
|
||||
return when (val backendError = this) {
|
||||
is BounceFailed -> StringValue.StringResource(backendError.toStringRes(), backendError.error.toStringRes())
|
||||
is BounceFailed ->
|
||||
StringValue.StringResource(
|
||||
backendError.toStringRes(),
|
||||
backendError.error.toStringRes(),
|
||||
)
|
||||
else -> StringValue.StringResource(backendError.toStringRes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -9,10 +9,11 @@ sealed class BackendMessage {
|
||||
|
||||
data object BounceRecovery : BackendMessage()
|
||||
|
||||
fun toStringRes() = when (this) {
|
||||
BounceRecovery -> R.string.pinger_bounce_recovery
|
||||
BounceSuccess -> R.string.pinger_bounce_successful
|
||||
}
|
||||
fun toStringRes() =
|
||||
when (this) {
|
||||
BounceRecovery -> R.string.pinger_bounce_recovery
|
||||
BounceSuccess -> R.string.pinger_bounce_successful
|
||||
}
|
||||
|
||||
fun toStringValue() = StringValue.StringResource(this.toStringRes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ data class TunnelConf(
|
||||
isPrimaryTunnel == other.isPrimaryTunnel &&
|
||||
isMobileDataTunnel == other.isMobileDataTunnel &&
|
||||
isEthernetTunnel == other.isEthernetTunnel &&
|
||||
pingTarget == other.pingTarget &&
|
||||
pingTarget == other.pingTarget &&
|
||||
restartOnPingFailure == other.restartOnPingFailure &&
|
||||
tunnelNetworks == other.tunnelNetworks &&
|
||||
isIpv4Preferred == other.isIpv4Preferred
|
||||
@@ -81,7 +81,7 @@ data class TunnelConf(
|
||||
amQuick,
|
||||
isActive,
|
||||
pingIp,
|
||||
restartOnPingFailure,
|
||||
restartOnPingFailure,
|
||||
isEthernetTunnel,
|
||||
isIpv4Preferred,
|
||||
position,
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ class AmneziaStatistics(private val statistics: Statistics) : TunnelStatistics()
|
||||
override fun tx(): Long {
|
||||
return statistics.totalTx()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-36
@@ -1,6 +1,5 @@
|
||||
package com.zaneschepke.wireguardautotunnel.domain.state
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.*
|
||||
@@ -16,15 +15,18 @@ data class AutoTunnelState(
|
||||
) {
|
||||
|
||||
fun determineAutoTunnelEvent(stateChange: StateChange): AutoTunnelEvent {
|
||||
when(val change = stateChange) {
|
||||
is StateChange.NetworkChange, is StateChange.SettingsChange -> {
|
||||
when (val change = stateChange) {
|
||||
is StateChange.NetworkChange,
|
||||
is StateChange.SettingsChange -> {
|
||||
// Compute desired tunnel based on network conditions
|
||||
var desiredTunnel: TunnelConf? = null
|
||||
if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) {
|
||||
desiredTunnel = preferredEthernetTunnel()
|
||||
} else if (isMobileDataActive() && settings.isTunnelOnMobileDataEnabled) {
|
||||
desiredTunnel = preferredMobileDataTunnel()
|
||||
} else if (isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted()) {
|
||||
} else if (
|
||||
isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted()
|
||||
) {
|
||||
desiredTunnel = preferredWifiTunnel()
|
||||
}
|
||||
|
||||
@@ -42,10 +44,12 @@ data class AutoTunnelState(
|
||||
// Start or switch to the desired tunnel (overrides any kill switch)
|
||||
return Start(desiredTunnel)
|
||||
}
|
||||
// If already active and matching, fall through to kill switch check (though unlikely needed)
|
||||
// If already active and matching, fall through to kill switch check (though
|
||||
// unlikely needed)
|
||||
} else {
|
||||
if (currentTunnel != null) {
|
||||
// Stop the active tunnel (then next emission can handle kill switch if needed)
|
||||
// Stop the active tunnel (then next emission can handle kill switch if
|
||||
// needed)
|
||||
return AutoTunnelEvent.Stop
|
||||
}
|
||||
}
|
||||
@@ -61,7 +65,7 @@ data class AutoTunnelState(
|
||||
}
|
||||
}
|
||||
is StateChange.MonitoringChange -> {
|
||||
val bounceTunnels = bounceOnPingFailed(change.consecutiveFailures)
|
||||
val bounceTunnels = bounceOnPingFailed()
|
||||
if (bounceTunnels.isNotEmpty()) {
|
||||
return Bounce(bounceTunnels)
|
||||
}
|
||||
@@ -75,22 +79,26 @@ data class AutoTunnelState(
|
||||
// also need to check for Wi-Fi state as there is some overlap when they are both connected
|
||||
private fun isMobileDataActive(): Boolean {
|
||||
return !networkState.isEthernetConnected &&
|
||||
!networkState.isWifiConnected &&
|
||||
networkState.isMobileDataConnected
|
||||
!networkState.isWifiConnected &&
|
||||
networkState.isMobileDataConnected
|
||||
}
|
||||
|
||||
private fun preferredMobileDataTunnel(): TunnelConf? {
|
||||
return tunnels.firstOrNull { it.isMobileDataTunnel }
|
||||
?: tunnels.firstOrNull { it.isPrimaryTunnel } ?: tunnels.firstOrNull()
|
||||
?: tunnels.firstOrNull { it.isPrimaryTunnel }
|
||||
?: tunnels.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredEthernetTunnel(): TunnelConf? {
|
||||
return tunnels.firstOrNull { it.isEthernetTunnel }
|
||||
?: tunnels.firstOrNull { it.isPrimaryTunnel } ?: tunnels.firstOrNull()
|
||||
?: tunnels.firstOrNull { it.isPrimaryTunnel }
|
||||
?: tunnels.firstOrNull()
|
||||
}
|
||||
|
||||
private fun preferredWifiTunnel(): TunnelConf? {
|
||||
return getTunnelWithMatchingTunnelNetwork() ?: tunnels.firstOrNull { it.isPrimaryTunnel } ?: tunnels.firstOrNull()
|
||||
return getTunnelWithMatchingTunnelNetwork()
|
||||
?: tunnels.firstOrNull { it.isPrimaryTunnel }
|
||||
?: tunnels.firstOrNull()
|
||||
}
|
||||
|
||||
// ignore cellular state as there is overlap where it may still be active, but not prioritized
|
||||
@@ -100,40 +108,37 @@ data class AutoTunnelState(
|
||||
|
||||
private fun stopKillSwitchOnTrusted(): Boolean {
|
||||
return networkState.isWifiConnected &&
|
||||
settings.isVpnKillSwitchEnabled &&
|
||||
settings.isDisableKillSwitchOnTrustedEnabled &&
|
||||
isCurrentSSIDTrusted()
|
||||
settings.isVpnKillSwitchEnabled &&
|
||||
settings.isDisableKillSwitchOnTrustedEnabled &&
|
||||
isCurrentSSIDTrusted()
|
||||
}
|
||||
|
||||
private fun startKillSwitch(): Boolean {
|
||||
return settings.isVpnKillSwitchEnabled &&
|
||||
(!settings.isDisableKillSwitchOnTrustedEnabled || !isCurrentSSIDTrusted())
|
||||
(!settings.isDisableKillSwitchOnTrustedEnabled || !isCurrentSSIDTrusted())
|
||||
}
|
||||
|
||||
private fun isNoConnectivity(): Boolean {
|
||||
return !networkState.isEthernetConnected &&
|
||||
!networkState.isWifiConnected &&
|
||||
!networkState.isMobileDataConnected
|
||||
!networkState.isWifiConnected &&
|
||||
!networkState.isMobileDataConnected
|
||||
}
|
||||
|
||||
private fun bounceOnPingFailed(failures: Map<TunnelConf, Int>) : List<Triple<TunnelConf, Map<String, String?>, Int>> {
|
||||
return activeTunnels.entries.filter { (tunnel, state) ->
|
||||
tunnel.restartOnPingFailure &&
|
||||
(state.pingStates?.any { (key , pingState) ->
|
||||
pingState.let { pingState ->
|
||||
(failures[tunnel] ?: 0) >= AutoTunnelService.CONSECUTIVE_FAILURE_THRESHOLD &&
|
||||
pingState.failureReason == FailureReason.PingFailed
|
||||
}
|
||||
private fun bounceOnPingFailed(): List<Pair<TunnelConf, Map<String, String?>>> {
|
||||
return activeTunnels.entries
|
||||
.filter { (tunnel, state) ->
|
||||
tunnel.restartOnPingFailure &&
|
||||
(state.pingStates?.any { (key, pingState) ->
|
||||
pingState.failureReason == FailureReason.PingFailed
|
||||
} ?: false)
|
||||
}.map { (tunnel, state) ->
|
||||
val maxFailures = state.pingStates?.maxOfOrNull { (key, pingState) ->
|
||||
failures[tunnel] ?: 0
|
||||
} ?: 0
|
||||
val peerMap = (state.statistics?.getPeers()?.associate { peerKey ->
|
||||
peerKey.toBase64() to state.statistics.peerStats(peerKey)?.resolvedEndpoint
|
||||
} ?: emptyMap())
|
||||
Triple(tunnel, peerMap, maxFailures)
|
||||
}
|
||||
}
|
||||
.map { (tunnel, state) ->
|
||||
val peerMap =
|
||||
(state.statistics?.getPeers()?.associate { peerKey ->
|
||||
peerKey.toBase64() to state.statistics.peerStats(peerKey)?.resolvedEndpoint
|
||||
} ?: emptyMap())
|
||||
Pair(tunnel, peerMap)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentSSIDTrusted(): Boolean {
|
||||
@@ -156,4 +161,4 @@ data class AutoTunnelState(
|
||||
tunnels.firstOrNull { hasTrustedWifiName(wifiName, it.tunnelNetworks) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ data class PingState(
|
||||
val lastPingAttemptMillis: Long? = null,
|
||||
val failureReason: FailureReason? = null,
|
||||
val pingTarget: String = CLOUDFLARE_IPV4_IP,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,4 +9,5 @@ data class TunnelState(
|
||||
val backendState: BackendState = BackendState.INACTIVE,
|
||||
val statistics: TunnelStatistics? = null,
|
||||
val pingStates: Map<Key, PingState>? = null,
|
||||
val handshakeSuccessLogs: Boolean? = null,
|
||||
)
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics
|
||||
override fun tx(): Long {
|
||||
return statistics.totalTx()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.animation
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
+1
-5
@@ -3,11 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
+2
-11
@@ -2,18 +2,9 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
+6
-3
@@ -29,7 +29,10 @@ fun <T> DropdownSelector(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (label != null) label()
|
||||
Text(text = currentValue?.toString() ?: stringResource(R.string._default), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = currentValue?.toString() ?: stringResource(R.string._default),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -40,8 +43,8 @@ fun <T> DropdownSelector(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
options.forEach { option ->
|
||||
if(option == null) {
|
||||
return@forEach DropdownMenuItem(
|
||||
if (option == null) {
|
||||
return@forEach DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string._default)) },
|
||||
onClick = {
|
||||
onValueSelected(null)
|
||||
|
||||
+10
-5
@@ -5,7 +5,14 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
|
||||
|
||||
@Composable
|
||||
fun LabelledNumberDropdown(title: @Composable () -> Unit, description: (@Composable () -> Unit)? = null, leading: @Composable () -> Unit, onSelected: (Int?) -> Unit, options: List<Int?>, currentValue: Int?) {
|
||||
fun LabelledNumberDropdown(
|
||||
title: @Composable () -> Unit,
|
||||
description: (@Composable () -> Unit)? = null,
|
||||
leading: @Composable () -> Unit,
|
||||
onSelected: (Int?) -> Unit,
|
||||
options: List<Int?>,
|
||||
currentValue: Int?,
|
||||
) {
|
||||
var isDropDownExpanded by remember { mutableStateOf(false) }
|
||||
SurfaceSelectionGroupButton(
|
||||
listOf(
|
||||
@@ -18,9 +25,7 @@ fun LabelledNumberDropdown(title: @Composable () -> Unit, description: (@Composa
|
||||
DropdownSelector(
|
||||
currentValue = currentValue,
|
||||
options = options,
|
||||
onValueSelected = { num ->
|
||||
onSelected(num)
|
||||
},
|
||||
onValueSelected = { num -> onSelected(num) },
|
||||
isExpanded = isDropDownExpanded,
|
||||
onDismiss = { isDropDownExpanded = false },
|
||||
)
|
||||
@@ -28,4 +33,4 @@ fun LabelledNumberDropdown(title: @Composable () -> Unit, description: (@Composa
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -1,12 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
|
||||
+1
-5
@@ -9,11 +9,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
+4
-2
@@ -40,11 +40,13 @@ fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) {
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
),
|
||||
)
|
||||
},
|
||||
leading = { Icon(Icons.Outlined.PauseCircle, null) },
|
||||
onSelected = { selected -> viewModel.handleEvent(AppEvent.SetDebounceDelay(selected!!)) },
|
||||
onSelected = { selected ->
|
||||
viewModel.handleEvent(AppEvent.SetDebounceDelay(selected!!))
|
||||
},
|
||||
options = (0..10).toList(),
|
||||
currentValue = appUiState.appSettings.debounceDelaySeconds,
|
||||
)
|
||||
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ fun TunnelAutoTunnelScreen(
|
||||
SurfaceSelectionGroupButton(
|
||||
items =
|
||||
buildList {
|
||||
if(appSettings.isPingEnabled) {
|
||||
add(PingRestartItem(tunnelConf,viewModel))
|
||||
if (appSettings.isPingEnabled) {
|
||||
add(PingRestartItem(tunnelConf, viewModel))
|
||||
}
|
||||
add(MobileDataTunnelItem(tunnelConf, viewModel))
|
||||
add(ethernetTunnelItem(tunnelConf, viewModel))
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Security
|
||||
import androidx.compose.material3.Icon
|
||||
|
||||
+2
-12
@@ -5,18 +5,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentPasteGo
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
+6
-1
@@ -112,7 +112,12 @@ fun TunnelRowItem(
|
||||
text = tunnel.tunName,
|
||||
expanded = {
|
||||
if (tunnelState.status != TunnelStatus.Down) {
|
||||
TunnelStatisticsRow(tunnelState, tunnel, appSettings.isPingEnabled, showDetailedStats)
|
||||
TunnelStatisticsRow(
|
||||
tunnelState,
|
||||
tunnel,
|
||||
appSettings.isPingEnabled,
|
||||
showDetailedStats,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
|
||||
+81
-42
@@ -16,7 +16,12 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString
|
||||
|
||||
@Composable
|
||||
fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingEnabled: Boolean, showDetailedStats: Boolean) {
|
||||
fun TunnelStatisticsRow(
|
||||
tunnelState: TunnelState,
|
||||
tunnelConf: TunnelConf,
|
||||
pingEnabled: Boolean,
|
||||
showDetailedStats: Boolean,
|
||||
) {
|
||||
val config = remember(tunnelConf) { TunnelConf.configFromAmQuick(tunnelConf.wgQuick) }
|
||||
val peerText = stringResource(R.string.peer)
|
||||
val handshakeText = stringResource(R.string.handshake)
|
||||
@@ -26,40 +31,63 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
val textColor = MaterialTheme.colorScheme.outline
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 45.dp, bottom = 10.dp, end = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 45.dp, bottom = 10.dp, end = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
config.peers.forEachIndexed { index, peer ->
|
||||
key(peer.publicKey.toBase64()) { // Key by peer ID to skip recomposition if unchanged
|
||||
val peerStats = remember(tunnelState.statistics, peer, tunnelConf) { tunnelState.statistics?.peerStats(peer.publicKey) }
|
||||
val peerId = remember(peer) { peer.publicKey.toBase64().subSequence(0, 3).toString() + "***" }
|
||||
val endpoint by remember(peerStats) { derivedStateOf { peerStats?.resolvedEndpoint } }
|
||||
val peerRxMB by remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats?.rxBytes?.let { NumberUtils.bytesToMB(it) }?.toThreeDecimalPlaceString() ?: "0.00"
|
||||
val peerStats =
|
||||
remember(tunnelState.statistics, peer, tunnelConf) {
|
||||
tunnelState.statistics?.peerStats(peer.publicKey)
|
||||
}
|
||||
}
|
||||
val peerTxMB by remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats?.txBytes?.let { NumberUtils.bytesToMB(it) }?.toThreeDecimalPlaceString() ?: "0.00"
|
||||
val peerId =
|
||||
remember(peer) {
|
||||
peer.publicKey.toBase64().subSequence(0, 3).toString() + "***"
|
||||
}
|
||||
}
|
||||
val handshake by remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats?.latestHandshakeEpochMillis?.let {
|
||||
if (it == 0L) null else NumberUtils.getSecondsBetweenTimestampAndNow(it).toString()
|
||||
val endpoint by
|
||||
remember(peerStats) { derivedStateOf { peerStats?.resolvedEndpoint } }
|
||||
val peerRxMB by
|
||||
remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats
|
||||
?.rxBytes
|
||||
?.let { NumberUtils.bytesToMB(it) }
|
||||
?.toThreeDecimalPlaceString() ?: "0.00"
|
||||
}
|
||||
}
|
||||
}
|
||||
val pingState by remember(tunnelState.pingStates) { derivedStateOf { tunnelState.pingStates?.getOrDefault(peer.publicKey, null) } }
|
||||
val lastPingedSeconds by remember(peerStats) {
|
||||
derivedStateOf {
|
||||
pingState?.lastSuccessfulPingMillis?.let { NumberUtils.getSecondsBetweenTimestampAndNow(it) }
|
||||
val peerTxMB by
|
||||
remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats
|
||||
?.txBytes
|
||||
?.let { NumberUtils.bytesToMB(it) }
|
||||
?.toThreeDecimalPlaceString() ?: "0.00"
|
||||
}
|
||||
}
|
||||
val handshake by
|
||||
remember(peerStats) {
|
||||
derivedStateOf {
|
||||
peerStats?.latestHandshakeEpochMillis?.let {
|
||||
if (it == 0L) null
|
||||
else NumberUtils.getSecondsBetweenTimestampAndNow(it).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
val pingState by
|
||||
remember(tunnelState.pingStates) {
|
||||
derivedStateOf {
|
||||
tunnelState.pingStates?.getOrDefault(peer.publicKey, null)
|
||||
}
|
||||
}
|
||||
val lastPingedSeconds by
|
||||
remember(peerStats) {
|
||||
derivedStateOf {
|
||||
pingState?.lastSuccessfulPingMillis?.let {
|
||||
NumberUtils.getSecondsBetweenTimestampAndNow(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group peer stats in a column with internal spacing
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
@@ -67,11 +95,7 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
"$peerText: $peerId",
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
Text("$peerText: $peerId", style = textStyle, color = textColor)
|
||||
Text(
|
||||
"$handshakeText: ${handshake?.let { stringResource(R.string.sec_ago_template, it)} ?: neverText}",
|
||||
style = textStyle,
|
||||
@@ -98,11 +122,7 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
"$endpointText: $endpoint",
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
Text("$endpointText: $endpoint", style = textStyle, color = textColor)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = pingState != null && pingEnabled) {
|
||||
@@ -113,12 +133,21 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.reachable_template, stringResource(if(it.isReachable) R.string._true else R.string._false) ),
|
||||
stringResource(
|
||||
R.string.reachable_template,
|
||||
stringResource(
|
||||
if (it.isReachable) R.string._true
|
||||
else R.string._false
|
||||
),
|
||||
),
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ping_target_template, it.pingTarget),
|
||||
stringResource(
|
||||
R.string.ping_target_template,
|
||||
it.pingTarget,
|
||||
),
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
@@ -144,12 +173,18 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.packets_sent_template, it.transmitted),
|
||||
stringResource(
|
||||
R.string.packets_sent_template,
|
||||
it.transmitted,
|
||||
),
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.packet_loss_template, it.packetLoss),
|
||||
stringResource(
|
||||
R.string.packet_loss_template,
|
||||
it.packetLoss,
|
||||
),
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
@@ -159,8 +194,12 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ping_success_template, lastPingedSeconds?.let { sec ->
|
||||
stringResource(R.string.sec_ago_template, sec) } ?: neverText),
|
||||
stringResource(
|
||||
R.string.ping_success_template,
|
||||
lastPingedSeconds?.let { sec ->
|
||||
stringResource(R.string.sec_ago_template, sec)
|
||||
} ?: neverText,
|
||||
),
|
||||
style = textStyle,
|
||||
color = textColor,
|
||||
)
|
||||
@@ -173,4 +212,4 @@ fun TunnelStatisticsRow(tunnelState : TunnelState, tunnelConf: TunnelConf, pingE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -7,11 +7,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
+1
-1
@@ -12,13 +12,13 @@ import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ConfigViewModel
|
||||
|
||||
+1
-6
@@ -5,12 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
|
||||
+1
-5
@@ -9,11 +9,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
|
||||
+2
-10
@@ -1,19 +1,11 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
+3
-18
@@ -2,28 +2,13 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
|
||||
+2
-6
@@ -15,15 +15,11 @@ import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class SplitTunnelViewModel
|
||||
|
||||
+1
-5
@@ -2,11 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.componen
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
|
||||
+1
-6
@@ -8,12 +8,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
+2
-4
@@ -77,11 +77,9 @@ fun TunnelOptionsScreen(
|
||||
SplitTunnelingItem(tunnelConf),
|
||||
)
|
||||
)
|
||||
if(appSettings.isPingEnabled) {
|
||||
if (appSettings.isPingEnabled) {
|
||||
SectionDivider()
|
||||
SurfaceSelectionGroupButton(
|
||||
items = listOf(pingConfigItem(tunnelConf, viewModel))
|
||||
)
|
||||
SurfaceSelectionGroupButton(items = listOf(pingConfigItem(tunnelConf, viewModel)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-29
@@ -2,30 +2,13 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.VpnKey
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -38,15 +21,7 @@ import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.setScreenBrightness
|
||||
import io.github.alexzhirkevich.qrose.options.QrBallShape
|
||||
import io.github.alexzhirkevich.qrose.options.QrBrush
|
||||
import io.github.alexzhirkevich.qrose.options.QrErrorCorrectionLevel
|
||||
import io.github.alexzhirkevich.qrose.options.QrFrameShape
|
||||
import io.github.alexzhirkevich.qrose.options.QrOptions
|
||||
import io.github.alexzhirkevich.qrose.options.QrPixelShape
|
||||
import io.github.alexzhirkevich.qrose.options.circle
|
||||
import io.github.alexzhirkevich.qrose.options.roundCorners
|
||||
import io.github.alexzhirkevich.qrose.options.solid
|
||||
import io.github.alexzhirkevich.qrose.options.*
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
|
||||
@Composable
|
||||
|
||||
+4
-4
@@ -24,12 +24,12 @@ fun pingConfigItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionIt
|
||||
value = tunnelConf.pingTarget,
|
||||
label = stringResource(R.string.set_custom_ping_target),
|
||||
hint = stringResource(R.string.ip_or_hostname),
|
||||
isErrorValue = { it?.isNotBlank() == true && !it.isValidIpv4orIpv6Address() &&
|
||||
isErrorValue = {
|
||||
it?.isNotBlank() == true &&
|
||||
!it.isValidIpv4orIpv6Address() &&
|
||||
!android.util.Patterns.DOMAIN_NAME.matcher(it).matches()
|
||||
},
|
||||
supportingText = {
|
||||
Text(stringResource(R.string.ping_target_description))
|
||||
},
|
||||
supportingText = { Text(stringResource(R.string.ping_target_description)) },
|
||||
onSubmit = { ip ->
|
||||
viewModel.handleEvent(AppEvent.SetTunnelPingTarget(tunnelConf, ip))
|
||||
},
|
||||
|
||||
+5
-11
@@ -14,17 +14,11 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
|
||||
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
|
||||
|
||||
@Composable
|
||||
fun tunnelMonitoringItem() : SelectionItem {
|
||||
fun tunnelMonitoringItem(): SelectionItem {
|
||||
val navController = LocalNavController.current
|
||||
return SelectionItem(
|
||||
leading = {
|
||||
Icon(Icons.Outlined.MonitorHeart, null)
|
||||
},
|
||||
trailing = {
|
||||
ForwardButton {
|
||||
navController.navigate(Route.TunnelMonitoring)
|
||||
}
|
||||
},
|
||||
leading = { Icon(Icons.Outlined.MonitorHeart, null) },
|
||||
trailing = { ForwardButton { navController.navigate(Route.TunnelMonitoring) } },
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.tunnel_monitoring),
|
||||
@@ -32,6 +26,6 @@ fun tunnelMonitoringItem() : SelectionItem {
|
||||
MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
},
|
||||
onClick = { navController.navigate(Route.TunnelMonitoring) }
|
||||
onClick = { navController.navigate(Route.TunnelMonitoring) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-8
@@ -6,14 +6,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
+1
-6
@@ -7,12 +7,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FolderZip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
+50
-34
@@ -29,45 +29,55 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
|
||||
@Composable
|
||||
fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) {
|
||||
val pingInterval: Int by remember(uiState.appSettings)
|
||||
{ mutableIntStateOf(uiState.appSettings.tunnelPingIntervalSeconds) }
|
||||
val pingAttempts: Int by remember(uiState.appSettings)
|
||||
{ mutableIntStateOf(uiState.appSettings.tunnelPingAttempts) }
|
||||
val pingTimeout: Int? by remember(uiState.appSettings)
|
||||
{ mutableStateOf(uiState.appSettings.tunnelPingTimeoutSeconds) }
|
||||
|
||||
val pingInterval: Int by
|
||||
remember(uiState.appSettings) {
|
||||
mutableIntStateOf(uiState.appSettings.tunnelPingIntervalSeconds)
|
||||
}
|
||||
val pingAttempts: Int by
|
||||
remember(uiState.appSettings) { mutableIntStateOf(uiState.appSettings.tunnelPingAttempts) }
|
||||
val pingTimeout: Int? by
|
||||
remember(uiState.appSettings) {
|
||||
mutableStateOf(uiState.appSettings.tunnelPingTimeoutSeconds)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
Modifier.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 24.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
SurfaceSelectionGroupButton(listOf(
|
||||
enablePingMonitoringItem(uiState,viewModel)
|
||||
))
|
||||
if(uiState.appSettings.isPingEnabled) {
|
||||
SurfaceSelectionGroupButton(listOf(enablePingMonitoringItem(uiState, viewModel)))
|
||||
if (uiState.appSettings.isPingEnabled) {
|
||||
LabelledNumberDropdown(
|
||||
title = { Text(text = stringResource(R.string.tunnel_ping_interval), style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)) },
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.tunnel_ping_interval),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
)
|
||||
},
|
||||
leading = { Icon(Icons.Outlined.Timer, contentDescription = null) },
|
||||
currentValue = pingInterval,
|
||||
onSelected = { selected ->
|
||||
viewModel.handleEvent(AppEvent.SetPingInterval(selected!!))
|
||||
},
|
||||
},
|
||||
options = (10..60).step(10).toList(),
|
||||
)
|
||||
LabelledNumberDropdown(
|
||||
title = { Text(text = stringResource(R.string.attempts_per_interval), style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)) },
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.attempts_per_interval),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
)
|
||||
},
|
||||
leading = { Icon(Icons.Outlined.Replay, contentDescription = null) },
|
||||
currentValue = pingAttempts,
|
||||
onSelected = { selected ->
|
||||
@@ -76,26 +86,32 @@ fun TunnelMonitoringScreen(uiState: AppUiState, viewModel: AppViewModel) {
|
||||
options = (1..5).toList(),
|
||||
)
|
||||
LabelledNumberDropdown(
|
||||
title = { Text(text = stringResource(R.string.ping_timeout), style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)) },
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.ping_timeout),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
)
|
||||
},
|
||||
leading = { Icon(Icons.Outlined.TimerOff, contentDescription = null) },
|
||||
currentValue = pingTimeout,
|
||||
description = {
|
||||
Text(text = stringResource(R.string.timeout_all_attempts), style = MaterialTheme.typography.bodySmall
|
||||
.copy(color = MaterialTheme.colorScheme.outline))
|
||||
Text(
|
||||
text = stringResource(R.string.timeout_all_attempts),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
),
|
||||
)
|
||||
},
|
||||
onSelected = { selected ->
|
||||
viewModel.handleEvent(AppEvent.SetPingTimeout(selected))
|
||||
},
|
||||
options = (10..20).toList() + null,
|
||||
)
|
||||
SurfaceSelectionGroupButton(
|
||||
listOf(
|
||||
detailedPingStatsItem(uiState, viewModel)
|
||||
)
|
||||
)
|
||||
SurfaceSelectionGroupButton(listOf(detailedPingStatsItem(uiState, viewModel)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-9
@@ -7,15 +7,15 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
@Composable
|
||||
fun detailedPingStatsItem(uiState: AppUiState, viewModel: AppViewModel) : SelectionItem {
|
||||
fun detailedPingStatsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
|
||||
return SelectionItem(
|
||||
leading = { Icon(Icons.Outlined.QueryStats, contentDescription = null) },
|
||||
title = {
|
||||
@@ -28,13 +28,9 @@ fun detailedPingStatsItem(uiState: AppUiState, viewModel: AppViewModel) : Select
|
||||
trailing = {
|
||||
ScaledSwitch(
|
||||
checked = uiState.appState.showDetailedPingStats,
|
||||
onClick = {
|
||||
viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats)
|
||||
},
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) },
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats)
|
||||
},
|
||||
onClick = { viewModel.handleEvent(AppEvent.ToggleShowDetailedPingStats) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,12 +7,12 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
@Composable
|
||||
fun enablePingMonitoringItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
|
||||
@@ -33,4 +33,4 @@ fun enablePingMonitoringItem(uiState: AppUiState, viewModel: AppViewModel): Sele
|
||||
},
|
||||
onClick = { viewModel.handleEvent(AppEvent.TogglePingMonitoring) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -12,12 +12,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
-3
@@ -1,9 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.Book
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material.icons.outlined.Balance
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.Policy
|
||||
|
||||
-2
@@ -2,9 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.outlined.CloudDownload
|
||||
import androidx.compose.material.icons.rounded.CloudDownload
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
+1
-6
@@ -5,12 +5,7 @@ import android.content.Context
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@@ -16,14 +16,14 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.installApk
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
|
||||
import java.io.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.amnezia.awg.config.Config
|
||||
import timber.log.Timber
|
||||
import java.io.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class FileUtils(private val context: Context, private val ioDispatcher: CoroutineDispatcher) {
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import com.vdurmont.semver4j.Semver
|
||||
import timber.log.Timber
|
||||
import java.math.BigDecimal
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.pow
|
||||
import timber.log.Timber
|
||||
|
||||
object NumberUtils {
|
||||
private const val BYTES_IN_KB = 1024.0
|
||||
|
||||
+1
-1
@@ -25,9 +25,9 @@ import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.tile.AutoTunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.core.service.tile.TunnelControlTile
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import timber.log.Timber
|
||||
|
||||
fun Context.openWebUrl(url: String): Result<Unit> {
|
||||
return kotlin
|
||||
|
||||
+1
-15
@@ -2,17 +2,15 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.wireguard.android.backend.BackendException
|
||||
import com.wireguard.config.Peer
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.HandshakeStatus
|
||||
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
|
||||
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import java.net.InetAddress
|
||||
import org.amnezia.awg.backend.Backend
|
||||
import org.amnezia.awg.backend.Tunnel
|
||||
import org.amnezia.awg.config.Config
|
||||
@@ -40,18 +38,6 @@ fun TunnelStatistics.PeerStats.handshakeStatus(): HandshakeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fun Peer.isReachable(): Boolean {
|
||||
val host =
|
||||
if (this.endpoint.isPresent) {
|
||||
this.endpoint.get().host
|
||||
} else {
|
||||
Constants.DEFAULT_PING_IP
|
||||
}
|
||||
Timber.d("Checking reachability of peer: $host")
|
||||
val reachable = InetAddress.getByName(host).isReachable(Constants.PING_TIMEOUT.toInt())
|
||||
return reachable
|
||||
}
|
||||
|
||||
fun TunnelStatistics?.asColor(): Color {
|
||||
return this?.mapPeerStats()
|
||||
?.map { it.value?.handshakeStatus() }
|
||||
|
||||
+21
-11
@@ -15,17 +15,23 @@ import kotlin.math.sqrt
|
||||
class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) {
|
||||
|
||||
/**
|
||||
* Performs a ping with stats, wrapped in a coroutine for async execution.
|
||||
* Dynamically handles IPv4/ICMP or IPv6/ICMPv6 based on the host.
|
||||
* Performs a ping with stats, wrapped in a coroutine for async execution. Dynamically handles
|
||||
* IPv4/ICMP or IPv6/ICMPv6 based on the host.
|
||||
*
|
||||
* @param host The host to ping (domain, IPv4, or IPv6 address).
|
||||
* @param count Number of ping attempts.
|
||||
* @param timeoutMillis Overall timeout in milliseconds for the entire operation.
|
||||
* @return PingStats if successful, with isReachable set based on whether any packets were received,
|
||||
* and lastSuccessfulPingMillis set to the approximate epoch millis of the last successful ping response.
|
||||
* @return PingStats if successful, with isReachable set based on whether any packets were
|
||||
* received, and lastSuccessfulPingMillis set to the approximate epoch millis of the last
|
||||
* successful ping response.
|
||||
* @throws IOException on failure (e.g., unknown host or other errors).
|
||||
* @throws TimeoutCancellationException on timeout.
|
||||
*/
|
||||
suspend fun pingWithStats(host: String, count: Int, timeoutMillis: Long = (count * 2000L)): PingStats {
|
||||
suspend fun pingWithStats(
|
||||
host: String,
|
||||
count: Int,
|
||||
timeoutMillis: Long = (count * 2000L),
|
||||
): PingStats {
|
||||
return withTimeout(timeoutMillis) {
|
||||
withContext(ioDispatcher) {
|
||||
val icmp = Icmp4a()
|
||||
@@ -34,7 +40,8 @@ class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) {
|
||||
var received = 0
|
||||
var lastSuccessTime: Long? = null
|
||||
|
||||
icmp.pingInterval(host, count = count, intervalMillis = 500)
|
||||
icmp
|
||||
.pingInterval(host, count = count, intervalMillis = 500)
|
||||
.onEach { status ->
|
||||
when (val result = status.result) {
|
||||
is Icmp.PingResult.Success -> {
|
||||
@@ -46,12 +53,14 @@ class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) {
|
||||
Timber.w("Ping failed with result: ${result.message}")
|
||||
}
|
||||
}
|
||||
}.catch{
|
||||
when(it) {
|
||||
}
|
||||
.catch {
|
||||
when (it) {
|
||||
is CancellationException -> Timber.d("Ping completed")
|
||||
else -> throw it
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
.collect()
|
||||
|
||||
if (rttList.isNotEmpty()) {
|
||||
stats.transmitted = count
|
||||
@@ -61,7 +70,8 @@ class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) {
|
||||
stats.rttAvg = rttList.average().round(2)
|
||||
stats.rttMax = rttList.maxOrNull()?.round(2) ?: 0.0
|
||||
val mean = stats.rttAvg
|
||||
stats.rttStddev = sqrt(rttList.map { (it - mean) * (it - mean) }.average()).round(2)
|
||||
stats.rttStddev =
|
||||
sqrt(rttList.map { (it - mean) * (it - mean) }.average()).round(2)
|
||||
stats.isReachable = received > 0
|
||||
stats.lastSuccessfulPingMillis = lastSuccessTime
|
||||
} else {
|
||||
@@ -71,4 +81,4 @@ class NetworkUtils(private val ioDispatcher: CoroutineDispatcher) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ data class PingStats(
|
||||
var rttMax: Double = 0.0,
|
||||
var rttStddev: Double = 0.0,
|
||||
var isReachable: Boolean = false,
|
||||
var lastSuccessfulPingMillis: Long? = null
|
||||
var lastSuccessfulPingMillis: Long? = null,
|
||||
) {
|
||||
fun handleOffline(): PingStats {
|
||||
return copy(
|
||||
@@ -20,7 +20,7 @@ data class PingStats(
|
||||
rttAvg = 0.0,
|
||||
rttMax = 0.0,
|
||||
rttStddev = 0.0,
|
||||
isReachable = false
|
||||
isReachable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.wireguard.android.util.RootShell
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
|
||||
import com.zaneschepke.networkmonitor.ConnectivityState
|
||||
import com.zaneschepke.networkmonitor.NetworkMonitor
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
@@ -27,7 +26,6 @@ import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.AppState
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.Theme
|
||||
@@ -38,6 +36,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -66,11 +65,18 @@ constructor(
|
||||
private val logReader: LogReader,
|
||||
private val fileUtils: FileUtils,
|
||||
private val shortcutManager: ShortcutManager,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
networkMonitor: NetworkMonitor,
|
||||
) : ViewModel() {
|
||||
|
||||
private var logsJob: Job? = null
|
||||
|
||||
private val _eventFlow =
|
||||
MutableSharedFlow<AppEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 10,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
private val tunnelMutex = Mutex()
|
||||
private val settingsMutex = Mutex()
|
||||
private val tunControlMutex = Mutex()
|
||||
@@ -90,19 +96,23 @@ constructor(
|
||||
|
||||
val uiState: StateFlow<AppUiState> =
|
||||
combine(
|
||||
appDataRepository.settings.flow,
|
||||
appDataRepository.tunnels.flow,
|
||||
appDataRepository.appState.flow,
|
||||
tunnelManager.activeTunnels,
|
||||
serviceManager.autoTunnelService.map { it != null },
|
||||
combine(
|
||||
appDataRepository.settings.flow,
|
||||
appDataRepository.tunnels.flow,
|
||||
appDataRepository.appState.flow,
|
||||
) { settings, tunnels, appState ->
|
||||
Triple(settings, tunnels, appState)
|
||||
},
|
||||
combine(
|
||||
tunnelManager.activeTunnels,
|
||||
serviceManager.autoTunnelService.map { it != null },
|
||||
) { activeTunnels, autoTunnel ->
|
||||
Pair(activeTunnels, autoTunnel)
|
||||
},
|
||||
networkMonitor.connectivityStateFlow,
|
||||
) { array ->
|
||||
val settings = array[0] as AppSettings
|
||||
val tunnels = array[1] as List<TunnelConf>
|
||||
val appState = array[2] as AppState
|
||||
val activeTunnels = array[3] as Map<TunnelConf, TunnelState>
|
||||
val autoTunnel = array[4] as Boolean
|
||||
val network = array[5] as ConnectivityState
|
||||
) { repoTriple, managerPair, network ->
|
||||
val (settings, tunnels, appState) = repoTriple
|
||||
val (activeTunnels, autoTunnel) = managerPair
|
||||
|
||||
AppUiState(
|
||||
appSettings = settings,
|
||||
@@ -116,7 +126,7 @@ constructor(
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope + ioDispatcher,
|
||||
SharingStarted.Companion.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
|
||||
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
|
||||
AppUiState(),
|
||||
)
|
||||
|
||||
@@ -129,36 +139,36 @@ constructor(
|
||||
if (state.appState.isLocalLogsEnabled) logsJob = startCollectingLogs()
|
||||
handleTunnelMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUiEvent(event: UiEvent): Job =
|
||||
viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) }
|
||||
|
||||
fun handleEvent(event: AppEvent): Job =
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
uiState.withFirstState { state ->
|
||||
_eventFlow.collect { event ->
|
||||
val state = uiState.value
|
||||
when (event) {
|
||||
AppEvent.ToggleLocalLogging ->
|
||||
handleToggleLocalLogging(state.appState.isLocalLogsEnabled)
|
||||
|
||||
is AppEvent.SetDebounceDelay ->
|
||||
handleSetDebounceDelay(state.appSettings, event.delay)
|
||||
|
||||
is AppEvent.CopySelectedTunnel -> handleCopySelectedTunnel(state.tunnels)
|
||||
is AppEvent.DeleteSelectedTunnels -> handleDeleteSelectedTunnels()
|
||||
is AppEvent.ImportTunnelFromClipboard ->
|
||||
handleClipboardImport(event.text, state.tunnels)
|
||||
|
||||
is AppEvent.ImportTunnelFromFile ->
|
||||
handleImportTunnelFromFile(event.data, state.tunnels)
|
||||
|
||||
is AppEvent.ImportTunnelFromUrl ->
|
||||
handleImportTunnelFromUrl(event.url, state.tunnels)
|
||||
|
||||
is AppEvent.ImportTunnelFromQrCode ->
|
||||
handleImportTunnelFromQr(event.qrCode, state.tunnels)
|
||||
|
||||
AppEvent.SetBatteryOptimizeDisableShown -> setBatteryOptimizeDisableShown()
|
||||
is AppEvent.StartTunnel -> handleStartTunnel(event.tunnel, state.appSettings)
|
||||
is AppEvent.StopTunnel -> handleStopTunnel(event.tunnel)
|
||||
AppEvent.ToggleAutoTunnel -> handleToggleAutoTunnel(state)
|
||||
is AppEvent.ToggleTunnelStatsExpanded ->
|
||||
handleToggleTunnelStats(event.tunnelId, state.appState)
|
||||
|
||||
AppEvent.ToggleAlwaysOn -> handleToggleAlwaysOnVPN(state.appSettings)
|
||||
AppEvent.TogglePinLock -> handlePinLockToggled(state.appState.isPinLockEnabled)
|
||||
AppEvent.SetLocationDisclosureShown -> setLocationDisclosureShown()
|
||||
@@ -173,26 +183,36 @@ constructor(
|
||||
is AppEvent.TogglePrimaryTunnel -> handleTogglePrimaryTunnel(event.tunnel)
|
||||
is AppEvent.AddTunnelRunSSID ->
|
||||
handleAddTunnelRunSSID(event.ssid, event.tunnel, state.tunnels)
|
||||
|
||||
is AppEvent.DeleteTunnelRunSSID ->
|
||||
handleRemoveTunnelRunSSID(event.ssid, event.tunnel)
|
||||
|
||||
is AppEvent.ToggleEthernetTunnel -> handleToggleEthernetTunnel(event.tunnel)
|
||||
is AppEvent.ToggleMobileDataTunnel -> handleToggleMobileDataTunnel(event.tunnel)
|
||||
AppEvent.ToggleAutoTunnelOnCellular ->
|
||||
handleToggleAutoTunnelOnCellular(state.appSettings)
|
||||
|
||||
AppEvent.ToggleAutoTunnelOnWifi ->
|
||||
handleToggleAutoTunnelOnWifi(state.appSettings)
|
||||
|
||||
is AppEvent.DeleteTrustedSSID ->
|
||||
handleDeleteTrustedSSID(event.ssid, state.appSettings)
|
||||
|
||||
AppEvent.ToggleAutoTunnelWildcards ->
|
||||
handleToggleAutoTunnelWildcards(state.appSettings)
|
||||
|
||||
is AppEvent.SaveTrustedSSID ->
|
||||
handleSaveTrustedSSID(event.ssid, state.appSettings)
|
||||
|
||||
AppEvent.ToggleAutoTunnelOnEthernet ->
|
||||
handleToggleTunnelOnEthernet(state.appSettings)
|
||||
|
||||
AppEvent.ToggleStopKillSwitchOnTrusted ->
|
||||
handleToggleStopKillSwitchOnTrusted(state.appSettings)
|
||||
|
||||
AppEvent.ToggleStopTunnelOnNoInternet ->
|
||||
handleToggleStopOnNoInternet(state.appSettings)
|
||||
|
||||
is AppEvent.ExportSelectedTunnels ->
|
||||
handleExportSelectedTunnels(event.configType, event.uri)
|
||||
|
||||
@@ -201,6 +221,7 @@ constructor(
|
||||
is AppEvent.ToggleRestartOnPingFailure -> handleTogglePingTunnel(event.tunnel)
|
||||
is AppEvent.SetTunnelPingTarget ->
|
||||
handleTunnelPingTargetChange(event.tunnelConf, event.host)
|
||||
|
||||
is AppEvent.SetBottomSheet -> handleSetBottomSheet(event.showSheet)
|
||||
AppEvent.DeleteLogs -> handleDeleteLogs()
|
||||
is AppEvent.SetScreenAction -> _screenCallback.update { event.callback }
|
||||
@@ -208,11 +229,13 @@ constructor(
|
||||
is AppEvent.ToggleSelectedTunnel -> handleToggleSelectedTunnel(event.tunnel)
|
||||
is AppEvent.ToggleSelectAllTunnels ->
|
||||
handleToggleSelectAllTunnels(state.tunnels)
|
||||
|
||||
AppEvent.VpnPermissionRequested -> requestVpnPermission(false)
|
||||
is AppEvent.AppReadyCheck -> handleAppReadyCheck(event.tunnels)
|
||||
is AppEvent.ShowMessage -> handleShowMessage(event.message)
|
||||
is AppEvent.PopBackStack ->
|
||||
_appViewState.update { it.copy(popBackStack = event.pop) }
|
||||
|
||||
AppEvent.ToggleRemoteControl -> handleToggleRemoteControl(state.appState)
|
||||
AppEvent.ClearSelectedTunnels -> clearSelectedTunnels()
|
||||
is AppEvent.SetShowModal ->
|
||||
@@ -220,17 +243,40 @@ constructor(
|
||||
|
||||
is AppEvent.SetDetectionMethod ->
|
||||
handleSetDetectionMethod(event.detectionMethod, state.appSettings)
|
||||
|
||||
is AppEvent.SaveAllConfigs -> saveAllTunnels(event.tunnels)
|
||||
AppEvent.ToggleShowDetailedPingStats -> handleToggleShowDetailedPingStats(state.appState)
|
||||
is AppEvent.SaveMonitoringSettings -> handleMonitoringSaveChanges(state.appSettings,
|
||||
event.pingInterval, event.tunnelPingAttempts, event.pingTimeout)
|
||||
AppEvent.ToggleShowDetailedPingStats ->
|
||||
handleToggleShowDetailedPingStats(state.appState)
|
||||
is AppEvent.SaveMonitoringSettings ->
|
||||
handleMonitoringSaveChanges(
|
||||
state.appSettings,
|
||||
event.pingInterval,
|
||||
event.tunnelPingAttempts,
|
||||
event.pingTimeout,
|
||||
)
|
||||
|
||||
AppEvent.TogglePingMonitoring -> handleTogglePingMonitoring(state.appSettings)
|
||||
is AppEvent.SetPingAttempts -> saveSettings(state.appSettings.copy(tunnelPingAttempts = event.count))
|
||||
is AppEvent.SetPingInterval -> saveSettings(state.appSettings.copy(tunnelPingIntervalSeconds = event.interval))
|
||||
is AppEvent.SetPingTimeout -> saveSettings(state.appSettings.copy(tunnelPingTimeoutSeconds = event.timeout))
|
||||
is AppEvent.SetPingAttempts ->
|
||||
saveSettings(state.appSettings.copy(tunnelPingAttempts = event.count))
|
||||
is AppEvent.SetPingInterval ->
|
||||
saveSettings(
|
||||
state.appSettings.copy(tunnelPingIntervalSeconds = event.interval)
|
||||
)
|
||||
is AppEvent.SetPingTimeout ->
|
||||
saveSettings(
|
||||
state.appSettings.copy(tunnelPingTimeoutSeconds = event.timeout)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUiEvent(event: UiEvent): Job =
|
||||
viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) }
|
||||
|
||||
fun handleEvent(event: AppEvent) {
|
||||
_eventFlow.tryEmit(event)
|
||||
}
|
||||
|
||||
private suspend fun handleTogglePingMonitoring(appSettings: AppSettings) {
|
||||
saveSettings(appSettings.copy(isPingEnabled = !appSettings.isPingEnabled))
|
||||
@@ -242,14 +288,16 @@ constructor(
|
||||
tunnelPingAttempts: Int,
|
||||
pingTimeout: Int?,
|
||||
) {
|
||||
saveSettings(appSettings.copy(
|
||||
tunnelPingIntervalSeconds = pingInterval,
|
||||
tunnelPingAttempts = tunnelPingAttempts,
|
||||
tunnelPingTimeoutSeconds = pingTimeout,
|
||||
))
|
||||
saveSettings(
|
||||
appSettings.copy(
|
||||
tunnelPingIntervalSeconds = pingInterval,
|
||||
tunnelPingAttempts = tunnelPingAttempts,
|
||||
tunnelPingTimeoutSeconds = pingTimeout,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleToggleShowDetailedPingStats(currentAppState : AppState) {
|
||||
private suspend fun handleToggleShowDetailedPingStats(currentAppState: AppState) {
|
||||
appDataRepository.appState.setShowDetailedPingStats(!currentAppState.showDetailedPingStats)
|
||||
}
|
||||
|
||||
@@ -345,21 +393,20 @@ constructor(
|
||||
launch {
|
||||
tunnelManager.errorEvents.collect { errorEvent ->
|
||||
handleShowMessage(
|
||||
when(val event = errorEvent.second) {
|
||||
when (val event = errorEvent.second) {
|
||||
is BackendError.BounceFailed -> event.toStringValue()
|
||||
else -> StringValue.StringResource(
|
||||
R.string.tunnel_error_template,
|
||||
errorEvent.second.toStringRes(),
|
||||
)
|
||||
else ->
|
||||
StringValue.StringResource(
|
||||
R.string.tunnel_error_template,
|
||||
errorEvent.second.toStringRes(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
tunnelManager.messageEvents.collect { messageEvent ->
|
||||
handleShowMessage(
|
||||
messageEvent.second.toStringValue()
|
||||
)
|
||||
handleShowMessage(messageEvent.second.toStringValue())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,6 +684,7 @@ constructor(
|
||||
|
||||
private suspend fun handleToggleIpv4(tunnelConf: TunnelConf) =
|
||||
saveTunnel(tunnelConf.copy(isIpv4Preferred = !tunnelConf.isIpv4Preferred))
|
||||
|
||||
private suspend fun handleThemeChange(theme: Theme) {
|
||||
appDataRepository.appState.setTheme(theme)
|
||||
}
|
||||
@@ -843,7 +891,7 @@ constructor(
|
||||
rootShell.get().start()
|
||||
handleShowMessage(StringValue.StringResource(R.string.root_accepted))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
handleShowMessage(StringValue.StringResource(R.string.error_root_denied))
|
||||
false
|
||||
}
|
||||
|
||||
@@ -77,7 +77,11 @@ sealed class AppEvent {
|
||||
|
||||
data class SetTheme(val theme: Theme) : AppEvent()
|
||||
|
||||
data class SaveMonitoringSettings(val pingInterval: Int, val tunnelPingAttempts: Int, val pingTimeout: Int?) : AppEvent()
|
||||
data class SaveMonitoringSettings(
|
||||
val pingInterval: Int,
|
||||
val tunnelPingAttempts: Int,
|
||||
val pingTimeout: Int?,
|
||||
) : AppEvent()
|
||||
|
||||
data class SetDetectionMethod(val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod) :
|
||||
AppEvent()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
import java.util.*
|
||||
|
||||
object LocalProperties {
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.zaneschepke.logcatter
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LogFileManager(
|
||||
private val logDir: String,
|
||||
|
||||
@@ -3,17 +3,11 @@ package com.zaneschepke.logcatter
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LogcatManager(pid: Int, logDir: String, maxFileSize: Long, maxFolderSize: Long) :
|
||||
LogReader, DefaultLifecycleObserver {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.zaneschepke.logcatter
|
||||
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class LogcatStreamReader(private val pid: Int, private val fileManager: LogFileManager) {
|
||||
private val bufferSize = 1024
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package com.zaneschepke.networkmonitor.shizuku
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStreamReader
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
@@ -12,6 +8,10 @@ import moe.shizuku.server.IRemoteProcess
|
||||
import moe.shizuku.server.IShizukuService
|
||||
import rikka.shizuku.Shizuku
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStreamReader
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class ShizukuShell(private val applicationScope: CoroutineScope) {
|
||||
interface CommandResultListener {
|
||||
|
||||
Reference in New Issue
Block a user