mirror of
https://github.com/wgtunnel/android.git
synced 2026-07-03 14:07:49 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e4f3c3e5 | |||
| dedef38541 | |||
| aa5adec902 | |||
| e269aa88d9 | |||
| 7c3eabb19b | |||
| acdefd80fa | |||
| 09fa9dabdd | |||
| 33a02262a8 | |||
| 14a7278747 | |||
| 8fd2d8f62f | |||
| 7be051a664 | |||
| 88fff0b31c | |||
| 99a3fba97f |
@@ -15,7 +15,7 @@ A clear and concise description of what the bug is.
|
||||
- Device: [e.g. Pixel 4a]
|
||||
- Android Version: [e.g. Android 13]
|
||||
- App Version [e.g. 3.3.3]
|
||||
- Backend: [e.g. Kernel, Userspace]
|
||||
- App mode: [e.g. Kernel, VPN, Proxy, Lockdown]
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
@@ -126,14 +126,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var pinManagerInitialized by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(appState.isAppLoaded) {
|
||||
if (appState.isAppLoaded) {
|
||||
if (appState.pinLockEnabled && !pinManagerInitialized) {
|
||||
PinManager.initialize(this@MainActivity)
|
||||
pinManagerInitialized = true
|
||||
}
|
||||
appState.locale.let { LocaleUtil.changeLocale(it) }
|
||||
}
|
||||
}
|
||||
@@ -197,7 +191,6 @@ class MainActivity : AppCompatActivity() {
|
||||
requestingAppMode = Pair(sideEffect.requestingMode, sideEffect.config)
|
||||
vpnActivity.launch(VpnService.prepare(this@MainActivity))
|
||||
}
|
||||
is GlobalSideEffect.ShareFile -> context.launchShareFile(sideEffect.file)
|
||||
is GlobalSideEffect.Snackbar ->
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(sideEffect.message.asString(context))
|
||||
@@ -271,7 +264,10 @@ class MainActivity : AppCompatActivity() {
|
||||
Route.Lock
|
||||
else Route.TunnelsGraph,
|
||||
) {
|
||||
composable<Route.Lock> { PinLockScreen() }
|
||||
composable<Route.Lock> {
|
||||
PinManager.initialize(context = this@MainActivity)
|
||||
PinLockScreen()
|
||||
}
|
||||
navigation<Route.TunnelsGraph>(
|
||||
startDestination = Route.Tunnels
|
||||
) {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0)
|
||||
if (tunnelId == STOP_ALL_TUNNELS_ID)
|
||||
return@launch tunnelManager.stopActiveTunnels()
|
||||
tunnelRepository.getById(tunnelId)?.let { tunnelManager.stopTunnel(it.id) }
|
||||
tunnelManager.stopTunnel(tunnelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -51,7 +51,8 @@ class WireGuardNotification @Inject constructor(@ApplicationContext override val
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
)
|
||||
@@ -94,12 +95,12 @@ class WireGuardNotification @Inject constructor(@ApplicationContext override val
|
||||
val pendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
extraId ?: 0,
|
||||
Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = notificationAction.name
|
||||
if (extraId != null) putExtra(EXTRA_ID, extraId)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_notification,
|
||||
|
||||
@@ -56,6 +56,10 @@ abstract class BaseTunnel(@ApplicationScope protected val applicationScope: Coro
|
||||
) {
|
||||
tunStatusMutex.withLock {
|
||||
activeTuns.update { currentTuns ->
|
||||
if (!currentTuns.containsKey(tunnelId) && status != TunnelStatus.Starting) {
|
||||
Timber.d("Ignoring update for inactive tunnel $tunnelId")
|
||||
return@update currentTuns
|
||||
}
|
||||
val existingState = currentTuns[tunnelId] ?: TunnelState()
|
||||
val newStatus = status ?: existingState.status
|
||||
if (newStatus == TunnelStatus.Down) {
|
||||
|
||||
+99
-84
@@ -21,8 +21,9 @@ import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.amnezia.awg.crypto.Key
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -41,6 +42,7 @@ constructor(
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : TunnelProvider {
|
||||
|
||||
private val monitoringMutex = Mutex()
|
||||
private val monitoringJobs = ConcurrentHashMap<Int, Job>()
|
||||
|
||||
private data class SideEffectState(
|
||||
@@ -55,9 +57,6 @@ constructor(
|
||||
val condition: (SideEffectState) -> Boolean,
|
||||
)
|
||||
|
||||
private val sideEffectChannelFlow =
|
||||
MutableStateFlow<Channel<SideEffectState>>(Channel(Channel.CONFLATED))
|
||||
|
||||
private val tunnelProviderFlow: StateFlow<TunnelProvider> = run {
|
||||
val currentBackend = AtomicReference(userspaceTunnel)
|
||||
val currentSettings = AtomicReference(GeneralSettings())
|
||||
@@ -105,61 +104,9 @@ constructor(
|
||||
override val activeTunnels: StateFlow<Map<Int, TunnelState>> = run {
|
||||
val activeTunsReference: AtomicReference<Map<Int, TunnelState>> =
|
||||
AtomicReference(emptyMap())
|
||||
|
||||
tunnelProviderFlow
|
||||
.flatMapLatest { backend ->
|
||||
// Create a new channel for each backend to reset side-effect processing
|
||||
val newChannel = Channel<SideEffectState>(Channel.CONFLATED)
|
||||
sideEffectChannelFlow.value = newChannel
|
||||
|
||||
val sideEffects =
|
||||
listOf(
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleTunnelServiceChange(s.settings.appMode, s.activeTuns)
|
||||
},
|
||||
condition = { s -> s.activeTuns.size != s.previouslyActive.size },
|
||||
),
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleTunnelsActiveChange(s.previouslyActive, s.activeTuns, s.tuns)
|
||||
},
|
||||
condition = { s -> s.activeTuns.size != s.previouslyActive.size },
|
||||
),
|
||||
// TODO Not for kernel mode for now
|
||||
SideEffectWithCondition(
|
||||
effect = { s -> handleTunnelMonitoringChanges(s.activeTuns, s.tuns) },
|
||||
condition = { s ->
|
||||
s.tuns.any {
|
||||
it.restartOnPingFailure && s.activeTuns.keys.contains(it.id)
|
||||
} && s.settings.appMode != AppMode.KERNEL
|
||||
},
|
||||
),
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleFullTunnelMonitoring(s.activeTuns, s.tuns, s.settings)
|
||||
},
|
||||
condition = { s -> s.activeTuns.keys != s.previouslyActive.keys },
|
||||
),
|
||||
)
|
||||
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
for (state in newChannel) {
|
||||
supervisorScope {
|
||||
sideEffects
|
||||
.filter { it.condition(state) }
|
||||
.forEach { sideEffect ->
|
||||
launch {
|
||||
try {
|
||||
sideEffect.effect(state)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Side effect failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
combine(
|
||||
backend.activeTunnels,
|
||||
tunnelsRepository.flow,
|
||||
@@ -171,9 +118,67 @@ constructor(
|
||||
.onStart { handleStateRestore() }
|
||||
.onEach { (activeTuns, tuns, settings) ->
|
||||
val previouslyActive = activeTunsReference.exchange(activeTuns)
|
||||
sideEffectChannelFlow.value.trySend(
|
||||
SideEffectState(activeTuns, tuns, settings, previouslyActive)
|
||||
)
|
||||
val state = SideEffectState(activeTuns, tuns, settings, previouslyActive)
|
||||
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
supervisorScope {
|
||||
val sideEffects =
|
||||
listOf(
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleTunnelServiceChange(s.settings.appMode, s.activeTuns)
|
||||
},
|
||||
condition = { s ->
|
||||
s.activeTuns.size != s.previouslyActive.size
|
||||
},
|
||||
),
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleTunnelsActiveChange(
|
||||
s.previouslyActive,
|
||||
s.activeTuns,
|
||||
s.tuns,
|
||||
)
|
||||
},
|
||||
condition = { s ->
|
||||
s.activeTuns.size != s.previouslyActive.size
|
||||
},
|
||||
),
|
||||
// TODO Not for kernel mode for now
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleTunnelMonitoringChanges(s.activeTuns, s.tuns)
|
||||
},
|
||||
condition = { s ->
|
||||
s.tuns.any {
|
||||
it.restartOnPingFailure &&
|
||||
s.activeTuns.keys.contains(it.id)
|
||||
} && s.settings.appMode != AppMode.KERNEL
|
||||
},
|
||||
),
|
||||
SideEffectWithCondition(
|
||||
effect = { s ->
|
||||
handleFullTunnelMonitoring(s.activeTuns, s.tuns, s.settings)
|
||||
},
|
||||
condition = { s ->
|
||||
s.activeTuns.keys != s.previouslyActive.keys
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
sideEffects
|
||||
.filter { it.condition(state) }
|
||||
.forEach { sideEffect ->
|
||||
launch {
|
||||
try {
|
||||
sideEffect.effect(state)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Side effect failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { (activeTuns, _, _) -> activeTuns }
|
||||
.stateIn(
|
||||
@@ -375,29 +380,39 @@ constructor(
|
||||
activeTuns: Map<Int, TunnelState>,
|
||||
configs: List<TunnelConf>,
|
||||
settings: GeneralSettings,
|
||||
) {
|
||||
val activeIds = activeTuns.keys
|
||||
val obsoleteIds = monitoringJobs.keys - activeIds
|
||||
obsoleteIds.forEach { id ->
|
||||
monitoringJobs[id]?.cancel()
|
||||
monitoringJobs.remove(id)
|
||||
) =
|
||||
monitoringMutex.withLock {
|
||||
val activeIds = activeTuns.keys.toSet()
|
||||
val currentJobs = monitoringJobs.keys.toSet()
|
||||
val obsoleteIds = currentJobs - activeIds
|
||||
|
||||
Timber.d(
|
||||
"Monitoring: Active IDs: $activeIds, Obsolete IDs: $obsoleteIds, Total jobs before: ${monitoringJobs.size}"
|
||||
)
|
||||
|
||||
obsoleteIds.forEach { id ->
|
||||
monitoringJobs[id]?.cancel()
|
||||
monitoringJobs.remove(id)
|
||||
}
|
||||
|
||||
activeIds.forEach { id ->
|
||||
if (monitoringJobs.containsKey(id)) return@forEach // Skip if already monitored
|
||||
val config = configs.find { it.id == id } ?: return@forEach
|
||||
val tunStateFlow =
|
||||
activeTunnels.map { it[id] }.stateIn(applicationScope + ioDispatcher)
|
||||
val newJob =
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
tunnelMonitor.startMonitoring(
|
||||
id,
|
||||
withLogs = settings.appMode != AppMode.KERNEL,
|
||||
tunStateFlow = tunStateFlow,
|
||||
getStatistics = { tunnelId -> getStatistics(tunnelId) },
|
||||
updateTunnelStatus = { tid, status, stats, pings, logHealth ->
|
||||
updateTunnelStatus(tid, null, stats, pings, logHealth)
|
||||
},
|
||||
)
|
||||
}
|
||||
monitoringJobs[id] = newJob
|
||||
}
|
||||
}
|
||||
activeIds.forEach { id ->
|
||||
if (monitoringJobs.contains(id)) return@forEach
|
||||
configs.find { it.id == id } ?: return@forEach
|
||||
val tunStateFlow = activeTunnels.map { it[id] }.stateIn(applicationScope + ioDispatcher)
|
||||
monitoringJobs[id] =
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
tunnelMonitor.startMonitoring(
|
||||
id,
|
||||
withLogs = settings.appMode != AppMode.KERNEL,
|
||||
tunStateFlow = tunStateFlow,
|
||||
getStatistics = { tunnelId -> getStatistics(tunnelId) },
|
||||
updateTunnelStatus = { tid, status, stats, pings, logHealth ->
|
||||
updateTunnelStatus(tid, null, stats, pings, logHealth)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-33
@@ -139,6 +139,7 @@ constructor(
|
||||
val updates = ConcurrentMap<Key, PingState>()
|
||||
|
||||
pingablePeers.forEach { peer ->
|
||||
ensureActive()
|
||||
val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState()
|
||||
|
||||
val allowedIpStr = peer.allowedIps.firstOrNull()?.toString()
|
||||
@@ -172,41 +173,45 @@ constructor(
|
||||
|
||||
val attemptTime = System.currentTimeMillis()
|
||||
runCatching {
|
||||
val pingStats =
|
||||
settings.tunnelPingTimeoutSeconds?.let {
|
||||
networkUtils.pingWithStats(
|
||||
host,
|
||||
settings.tunnelPingAttempts,
|
||||
it.toMillis(),
|
||||
)
|
||||
}
|
||||
?: networkUtils.pingWithStats(
|
||||
host,
|
||||
settings.tunnelPingAttempts,
|
||||
)
|
||||
withTimeout(
|
||||
settings.tunnelPingTimeoutSeconds?.toMillis() ?: 5000L
|
||||
) {
|
||||
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,
|
||||
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"
|
||||
)
|
||||
Timber.d(
|
||||
"Ping completed for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats"
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(
|
||||
@@ -224,6 +229,7 @@ constructor(
|
||||
}
|
||||
|
||||
if (updates.isNotEmpty()) {
|
||||
ensureActive()
|
||||
pingStatsFlow.update { updates }
|
||||
updateTunnelStatus(tunnelConf.id, null, null, updates, null)
|
||||
}
|
||||
@@ -236,6 +242,7 @@ constructor(
|
||||
delay(3_000L)
|
||||
|
||||
while (isActive) {
|
||||
ensureActive()
|
||||
if (isNetworkConnected.value) {
|
||||
performPing()
|
||||
} else {
|
||||
@@ -248,6 +255,7 @@ constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
ensureActive()
|
||||
updateTunnelStatus(tunnelConf.id, null, null, pingStatsFlow.value, null)
|
||||
}
|
||||
delay(settings.tunnelPingIntervalSeconds.toMillis())
|
||||
@@ -264,7 +272,9 @@ constructor(
|
||||
) -> Unit,
|
||||
) = coroutineScope {
|
||||
while (isActive) {
|
||||
ensureActive()
|
||||
val stats = getStatistics(tunnelId)
|
||||
ensureActive()
|
||||
updateTunnelStatus(tunnelId, null, stats, null, null)
|
||||
delay(STATS_DELAY)
|
||||
}
|
||||
|
||||
-2
@@ -14,8 +14,6 @@ sealed class GlobalSideEffect {
|
||||
|
||||
data object PopBackStack : GlobalSideEffect()
|
||||
|
||||
data class ShareFile(val file: File) : GlobalSideEffect()
|
||||
|
||||
data class LaunchUrl(val url: String) : GlobalSideEffect()
|
||||
|
||||
data object ConfigChanged : GlobalSideEffect()
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.textbox
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SubmitConfigurationTextBox(
|
||||
value: String?,
|
||||
label: String,
|
||||
hint: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isErrorValue: (value: String?) -> Boolean,
|
||||
onSubmit: (value: String) -> Unit,
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
keyboardOptions: KeyboardOptions =
|
||||
KeyboardOptions(capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done),
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var stateValue by remember { mutableStateOf(value ?: "") }
|
||||
|
||||
CustomTextField(
|
||||
isError = isErrorValue(stateValue),
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
value = stateValue,
|
||||
onValueChange = { stateValue = it },
|
||||
interactionSource = interactionSource,
|
||||
supportingText = supportingText,
|
||||
label = {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
placeholder = {
|
||||
Text(
|
||||
hint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth().height(48.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
onSubmit(stateValue)
|
||||
keyboardController?.hide()
|
||||
}
|
||||
),
|
||||
trailing = {
|
||||
if (!isErrorValue(stateValue) && isFocused) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSubmit(stateValue)
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
) {
|
||||
val icon = Icons.Outlined.Save
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+9
-6
@@ -24,12 +24,18 @@ fun PinLockScreen() {
|
||||
val pinAlreadyExists by rememberSaveable { mutableStateOf(PinManager.pinExists()) }
|
||||
var pinCreated by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun onPinCorrect() {
|
||||
sharedViewModel.authenticated()
|
||||
navController.popBackStack()
|
||||
navController.navigate(Route.TunnelsGraph)
|
||||
}
|
||||
|
||||
PinLock(
|
||||
title = {
|
||||
Text(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
text =
|
||||
if (pinAlreadyExists || pinCreated) {
|
||||
if (pinAlreadyExists) {
|
||||
stringResource(id = R.string.enter_pin)
|
||||
} else {
|
||||
stringResource(id = R.string.create_pin)
|
||||
@@ -38,11 +44,7 @@ fun PinLockScreen() {
|
||||
},
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
onPinCorrect = {
|
||||
sharedViewModel.authenticated()
|
||||
navController.popBackStack()
|
||||
navController.navigate(Route.TunnelsGraph)
|
||||
},
|
||||
onPinCorrect = { onPinCorrect() },
|
||||
onPinIncorrect = {
|
||||
sharedViewModel.showToast(StringValue.StringResource(R.string.incorrect_pin))
|
||||
},
|
||||
@@ -50,6 +52,7 @@ fun PinLockScreen() {
|
||||
pinCreated = true
|
||||
sharedViewModel.showToast(StringValue.StringResource(R.string.pin_created))
|
||||
sharedViewModel.setPinLockEnabled(true)
|
||||
onPinCorrect()
|
||||
},
|
||||
)
|
||||
BackHandler(enabled = (!pinAlreadyExists && !pinCreated)) {
|
||||
|
||||
-2
@@ -32,7 +32,6 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.asString
|
||||
import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel
|
||||
import org.orbitmvi.orbit.compose.collectSideEffect
|
||||
import xyz.teamgravity.pin_lock_compose.PinManager
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
|
||||
@@ -158,7 +157,6 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
|
||||
add(
|
||||
pinLockItem(settingsState.isPinLockEnabled) { enabled ->
|
||||
if (enabled) {
|
||||
PinManager.initialize(context)
|
||||
navController.navigate(Route.Lock)
|
||||
} else {
|
||||
sharedViewModel.setPinLockEnabled(false)
|
||||
|
||||
+10
-1
@@ -69,7 +69,16 @@ fun LogsScreen(viewModel: LoggerViewModel = hiltViewModel()) {
|
||||
}
|
||||
|
||||
if (showLogsSheet) {
|
||||
LogsBottomSheet({ viewModel.exportLogs() }, { viewModel.deleteLogs() }) {
|
||||
LogsBottomSheet(
|
||||
{ uri ->
|
||||
viewModel.exportLogs(uri)
|
||||
showLogsSheet = false
|
||||
},
|
||||
{
|
||||
viewModel.deleteLogs()
|
||||
showLogsSheet = false
|
||||
},
|
||||
) {
|
||||
showLogsSheet = false
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -1,24 +1,53 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.FolderZip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogsBottomSheet(onExport: () -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit) {
|
||||
fun LogsBottomSheet(onExport: (file: Uri?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val selectedTunnelsExportLauncher =
|
||||
rememberFileExportLauncherForResult(
|
||||
mimeType = FileUtils.ZIP_FILE_MIME_TYPE,
|
||||
onResult = { file ->
|
||||
if (file != null) {
|
||||
onExport(file)
|
||||
} else onDismiss()
|
||||
},
|
||||
)
|
||||
|
||||
fun handleFileExport() {
|
||||
if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) {
|
||||
selectedTunnelsExportLauncher.launch(
|
||||
"${Constants.BASE_LOG_FILE_NAME}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip"
|
||||
)
|
||||
} else {
|
||||
onExport(null)
|
||||
}
|
||||
}
|
||||
|
||||
CustomBottomSheet(
|
||||
listOf(
|
||||
SheetOption(
|
||||
Icons.Outlined.FolderZip,
|
||||
stringResource(R.string.export_logs),
|
||||
onClick = onExport,
|
||||
onClick = { handleFileExport() },
|
||||
),
|
||||
SheetOption(
|
||||
Icons.Outlined.Delete,
|
||||
|
||||
+7
-2
@@ -11,9 +11,9 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileExportLauncherForResult
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun ExportTunnelsBottomSheet(
|
||||
@@ -37,7 +37,12 @@ fun ExportTunnelsBottomSheet(
|
||||
|
||||
fun handleFileExport() {
|
||||
if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) {
|
||||
selectedTunnelsExportLauncher.launch(Constants.DEFAULT_EXPORT_FILE_NAME)
|
||||
val fileName =
|
||||
when (exportConfigType) {
|
||||
ConfigType.AM -> "am_export_${Instant.now().epochSecond}.zip"
|
||||
ConfigType.WG -> "wg_export_${Instant.now().epochSecond}.zip"
|
||||
}
|
||||
selectedTunnelsExportLauncher.launch(fileName)
|
||||
} else {
|
||||
onExport(exportConfigType, null)
|
||||
}
|
||||
|
||||
+85
-10
@@ -1,16 +1,36 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.SubmitConfigurationTextBox
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
|
||||
|
||||
@Composable
|
||||
@@ -18,18 +38,73 @@ fun pingConfigItem(tunnelConf: TunnelConf, onSubmit: (ip: String) -> Unit): Sele
|
||||
return SelectionItem(
|
||||
title = {},
|
||||
description = {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var stateValue by remember { mutableStateOf(tunnelConf.pingTarget ?: "") }
|
||||
val isError by
|
||||
remember(stateValue) {
|
||||
derivedStateOf {
|
||||
stateValue.isNotBlank() &&
|
||||
!stateValue.isValidIpv4orIpv6Address() &&
|
||||
!Patterns.DOMAIN_NAME.matcher(stateValue).matches()
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SubmitConfigurationTextBox(
|
||||
value = tunnelConf.pingTarget,
|
||||
label = stringResource(R.string.set_custom_ping_target),
|
||||
hint = stringResource(R.string.ip_or_hostname),
|
||||
isErrorValue = {
|
||||
it?.isNotBlank() == true &&
|
||||
!it.isValidIpv4orIpv6Address() &&
|
||||
!Patterns.DOMAIN_NAME.matcher(it).matches()
|
||||
CustomTextField(
|
||||
isError = isError,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
value = stateValue,
|
||||
onValueChange = { stateValue = it },
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
label = {
|
||||
Text(
|
||||
stringResource(R.string.set_custom_ping_target),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(R.string.ip_or_hostname),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
supportingText = { Text(stringResource(R.string.ping_target_description)) },
|
||||
onSubmit = onSubmit,
|
||||
modifier =
|
||||
Modifier.padding(top = 5.dp, bottom = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(end = 16.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { onSubmit(stateValue) }),
|
||||
trailing = {
|
||||
if (!isError) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSubmit(stateValue)
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
) {
|
||||
val icon = Icons.Outlined.Save
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,8 +9,6 @@ object Constants {
|
||||
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1 shl 10
|
||||
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
|
||||
|
||||
const val DEFAULT_EXPORT_FILE_NAME = "wgtunnel-export.zip"
|
||||
|
||||
const val QR_CODE_NAME_PROPERTY = "# Name ="
|
||||
|
||||
const val FDROID_FLAVOR = "fdroid"
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ constructor(
|
||||
}
|
||||
|
||||
fun setTunnelOnEthernet(to: Boolean) = intent {
|
||||
settingsRepository.save(state.generalSettings.copy(isTunnelOnMobileDataEnabled = to))
|
||||
settingsRepository.save(state.generalSettings.copy(isTunnelOnEthernetEnabled = to))
|
||||
}
|
||||
|
||||
fun setDebounceDelay(to: Int) = intent {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.zaneschepke.wireguardautotunnel.viewmodel
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
|
||||
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository
|
||||
@@ -11,7 +13,6 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.StringValue
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -71,18 +72,30 @@ constructor(
|
||||
globalEffectRepository.post(globalSideEffect)
|
||||
}
|
||||
|
||||
fun exportLogs() = intent {
|
||||
fun exportLogs(uri: Uri?) = intent {
|
||||
val result =
|
||||
fileUtils.createNewShareFile(
|
||||
"${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip"
|
||||
"${Constants.BASE_LOG_FILE_NAME}_${BuildConfig.VERSION_NAME}_${BuildConfig.FLAVOR}.zip"
|
||||
)
|
||||
result.onSuccess { file -> postSideEffect(GlobalSideEffect.ShareFile(file)) }
|
||||
result.onFailure { error ->
|
||||
val message =
|
||||
error.message?.let { StringValue.DynamicString(it) }
|
||||
?: StringValue.StringResource(R.string.unknown_error)
|
||||
postSideEffect(GlobalSideEffect.Toast(message))
|
||||
val onFailure = { action: Throwable ->
|
||||
Timber.e(action)
|
||||
intent {
|
||||
postSideEffect(
|
||||
GlobalSideEffect.Toast(
|
||||
StringValue.StringResource(
|
||||
R.string.export_failed,
|
||||
": ${action.localizedMessage}",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
result.onSuccess { file ->
|
||||
logReader.zipLogFiles(file.absolutePath)
|
||||
fileUtils.exportFile(file, uri, FileUtils.ZIP_FILE_MIME_TYPE).onFailure(onFailure)
|
||||
}
|
||||
result.onFailure(onFailure)
|
||||
}
|
||||
|
||||
fun deleteLogs() = intent {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="files" path="." />
|
||||
<external-path name="external_files" path="." />
|
||||
<external-files-path name="apks" path="/" />
|
||||
</paths>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="files" path="." />
|
||||
<external-path name="external_files" path="." />
|
||||
<external-files-path name="apks" path="/" />
|
||||
<cache-path name="cache_files" path="external_files/" />
|
||||
</paths>
|
||||
@@ -1,6 +1,6 @@
|
||||
object Constants {
|
||||
const val VERSION_NAME = "4.0.0"
|
||||
const val VERSION_CODE = 40000
|
||||
const val VERSION_NAME = "4.0.3"
|
||||
const val VERSION_CODE = 40003
|
||||
const val TARGET_SDK = 36
|
||||
const val MIN_SDK = 26
|
||||
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
What's new:
|
||||
- Search domain tunnels fail to start bugfix
|
||||
- DNS fallback to IPv4 on IPv4 only networks bugfix
|
||||
- Ping target not editable bugfix
|
||||
@@ -0,0 +1,5 @@
|
||||
What's new:
|
||||
- App lock crash bugfix
|
||||
- Fdroid publishing bugfix
|
||||
- Exporting logs bugfix
|
||||
- Auto-tunnel ethernet toggle bugfix
|
||||
@@ -0,0 +1,4 @@
|
||||
What's new:
|
||||
- Monitoring failing to shut down race bugfix
|
||||
- Notifications stop action bugfix
|
||||
- Notification relaunch activity when already active bugfix
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
accompanist = "0.37.3"
|
||||
activityCompose = "1.11.0"
|
||||
amneziawgAndroid = "2.1.8"
|
||||
amneziawgAndroid = "2.1.9"
|
||||
androidx-junit = "1.3.0"
|
||||
icmp4a = "1.0.0"
|
||||
orbitCompose = "10.0.0"
|
||||
@@ -12,28 +12,28 @@ coreKtx = "1.17.0"
|
||||
datastorePreferences = "1.2.0-alpha02"
|
||||
desugar_jdk_libs = "2.1.5"
|
||||
espressoCore = "3.7.0"
|
||||
hiltAndroid = "2.57.1"
|
||||
hiltAndroid = "2.57.2"
|
||||
hiltCompiler = "1.3.0"
|
||||
junit = "4.13.2"
|
||||
kotlinx-serialization-json = "1.9.0"
|
||||
ktorClientCore = "3.3.0"
|
||||
lifecycle-runtime-compose = "2.9.4"
|
||||
material3 = "1.3.2"
|
||||
navigationCompose = "2.9.4"
|
||||
material3 = "1.4.0"
|
||||
navigationCompose = "2.9.5"
|
||||
pinLockCompose = "1.0.5"
|
||||
qrose = "1.0.1"
|
||||
roomVersion = "2.8.0"
|
||||
roomVersion = "2.8.1"
|
||||
semver4j = "3.1.0"
|
||||
slf4jAndroid = "1.7.36"
|
||||
timber = "5.0.1"
|
||||
tunnel = "1.4.0"
|
||||
androidGradlePlugin = "8.12.0"
|
||||
androidGradlePlugin = "8.11.1"
|
||||
kotlin = "2.2.20"
|
||||
ksp = "2.2.20-2.0.3"
|
||||
composeBom = "2025.09.00"
|
||||
compose = "1.9.1"
|
||||
composeBom = "2025.09.01"
|
||||
compose = "1.9.2"
|
||||
icons = "1.7.8"
|
||||
workRuntimeKtxVersion = "2.10.4"
|
||||
workRuntimeKtxVersion = "2.10.5"
|
||||
zxingAndroidEmbedded = "4.3.0"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gradlePlugins-grgit = "5.3.3"
|
||||
|
||||
@@ -8,7 +8,7 @@ interface LogReader {
|
||||
|
||||
fun stop()
|
||||
|
||||
fun zipLogFiles(path: String)
|
||||
suspend fun zipLogFiles(path: String)
|
||||
|
||||
suspend fun deleteAndClearLogs()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.zaneschepke.logcatter
|
||||
|
||||
import android.R.attr.path
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
@@ -61,15 +62,13 @@ class LogcatManager(pid: Int, logDir: String, maxFileSize: Long, maxFolderSize:
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
override fun zipLogFiles(path: String) {
|
||||
logScope.launch {
|
||||
val wasStarted = isStarted
|
||||
stop()
|
||||
fileManager.zipLogs(path)
|
||||
if (wasStarted) {
|
||||
logcatReader.clearLogs()
|
||||
start()
|
||||
}
|
||||
override suspend fun zipLogFiles(path: String) {
|
||||
val wasStarted = isStarted
|
||||
stop()
|
||||
fileManager.zipLogs(path)
|
||||
if (wasStarted) {
|
||||
logcatReader.clearLogs()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user