Compare commits

..

13 Commits

Author SHA1 Message Date
Zane Schepke b7e4f3c3e5 chore: bump version to 4.0.3 2025-09-27 06:18:13 -04:00
Zane Schepke dedef38541 fix: notification activity relaunch bug 2025-09-27 06:16:53 -04:00
Zane Schepke aa5adec902 fix: notification stop action bug, monitoring failing to shut down race 2025-09-27 06:07:54 -04:00
Zane Schepke e269aa88d9 chore: bump verison to 4.0.2 2025-09-26 09:51:15 -04:00
Zane Schepke 7c3eabb19b fix: logs export bug, export file naming 2025-09-26 09:46:30 -04:00
Zane Schepke acdefd80fa fix: fdroid agp build issue, bump deps
closes #971
2025-09-26 08:31:17 -04:00
Zane Schepke 09fa9dabdd fix: tunnel on ethernet toggle
closes #978
2025-09-26 08:23:53 -04:00
Zane Schepke 33a02262a8 fix: pin lock crash bug
closes #967
2025-09-26 08:20:01 -04:00
Zane Schepke 14a7278747 chore: bump for patch version 2025-09-25 01:43:57 -04:00
Zane Schepke 8fd2d8f62f fix: ipv4 fallback, proxy search domain support and parser fix
#961
#960
2025-09-23 07:34:34 -04:00
Zane Schepke 7be051a664 fix: can't edit ping target bug
closes #959
2025-09-23 07:32:07 -04:00
Zane Schepke 88fff0b31c fix: logs export bug
#960
2025-09-23 03:58:19 -04:00
Zane Schepke 99a3fba97f chore: update issue template 2025-09-22 20:39:15 -04:00
26 changed files with 360 additions and 287 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ A clear and concise description of what the bug is.
- Device: [e.g. Pixel 4a] - Device: [e.g. Pixel 4a]
- Android Version: [e.g. Android 13] - Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3] - App Version [e.g. 3.3.3]
- Backend: [e.g. Kernel, Userspace] - App mode: [e.g. Kernel, VPN, Proxy, Lockdown]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
@@ -126,14 +126,8 @@ class MainActivity : AppCompatActivity() {
val navController = rememberNavController() val navController = rememberNavController()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pinManagerInitialized by remember { mutableStateOf(false) }
LaunchedEffect(appState.isAppLoaded) { LaunchedEffect(appState.isAppLoaded) {
if (appState.isAppLoaded) { if (appState.isAppLoaded) {
if (appState.pinLockEnabled && !pinManagerInitialized) {
PinManager.initialize(this@MainActivity)
pinManagerInitialized = true
}
appState.locale.let { LocaleUtil.changeLocale(it) } appState.locale.let { LocaleUtil.changeLocale(it) }
} }
} }
@@ -197,7 +191,6 @@ class MainActivity : AppCompatActivity() {
requestingAppMode = Pair(sideEffect.requestingMode, sideEffect.config) requestingAppMode = Pair(sideEffect.requestingMode, sideEffect.config)
vpnActivity.launch(VpnService.prepare(this@MainActivity)) vpnActivity.launch(VpnService.prepare(this@MainActivity))
} }
is GlobalSideEffect.ShareFile -> context.launchShareFile(sideEffect.file)
is GlobalSideEffect.Snackbar -> is GlobalSideEffect.Snackbar ->
scope.launch { scope.launch {
snackbar.showSnackbar(sideEffect.message.asString(context)) snackbar.showSnackbar(sideEffect.message.asString(context))
@@ -271,7 +264,10 @@ class MainActivity : AppCompatActivity() {
Route.Lock Route.Lock
else Route.TunnelsGraph, else Route.TunnelsGraph,
) { ) {
composable<Route.Lock> { PinLockScreen() } composable<Route.Lock> {
PinManager.initialize(context = this@MainActivity)
PinLockScreen()
}
navigation<Route.TunnelsGraph>( navigation<Route.TunnelsGraph>(
startDestination = Route.Tunnels startDestination = Route.Tunnels
) { ) {
@@ -34,7 +34,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0) val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0)
if (tunnelId == STOP_ALL_TUNNELS_ID) if (tunnelId == STOP_ALL_TUNNELS_ID)
return@launch tunnelManager.stopActiveTunnels() return@launch tunnelManager.stopActiveTunnels()
tunnelRepository.getById(tunnelId)?.let { tunnelManager.stopTunnel(it.id) } tunnelManager.stopTunnel(tunnelId)
} }
} }
} }
@@ -51,7 +51,8 @@ class WireGuardNotification @Inject constructor(@ApplicationContext override val
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
0, 0,
Intent(context, MainActivity::class.java), Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
) )
@@ -94,12 +95,12 @@ class WireGuardNotification @Inject constructor(@ApplicationContext override val
val pendingIntent = val pendingIntent =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, context,
0, extraId ?: 0,
Intent(context, NotificationActionReceiver::class.java).apply { Intent(context, NotificationActionReceiver::class.java).apply {
action = notificationAction.name action = notificationAction.name
if (extraId != null) putExtra(EXTRA_ID, extraId) if (extraId != null) putExtra(EXTRA_ID, extraId)
}, },
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
return NotificationCompat.Action.Builder( return NotificationCompat.Action.Builder(
R.drawable.ic_notification, R.drawable.ic_notification,
@@ -56,6 +56,10 @@ abstract class BaseTunnel(@ApplicationScope protected val applicationScope: Coro
) { ) {
tunStatusMutex.withLock { tunStatusMutex.withLock {
activeTuns.update { currentTuns -> 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 existingState = currentTuns[tunnelId] ?: TunnelState()
val newStatus = status ?: existingState.status val newStatus = status ?: existingState.status
if (newStatus == TunnelStatus.Down) { if (newStatus == TunnelStatus.Down) {
@@ -21,8 +21,9 @@ import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.amnezia.awg.crypto.Key import org.amnezia.awg.crypto.Key
import timber.log.Timber import timber.log.Timber
@@ -41,6 +42,7 @@ constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelProvider { ) : TunnelProvider {
private val monitoringMutex = Mutex()
private val monitoringJobs = ConcurrentHashMap<Int, Job>() private val monitoringJobs = ConcurrentHashMap<Int, Job>()
private data class SideEffectState( private data class SideEffectState(
@@ -55,9 +57,6 @@ constructor(
val condition: (SideEffectState) -> Boolean, val condition: (SideEffectState) -> Boolean,
) )
private val sideEffectChannelFlow =
MutableStateFlow<Channel<SideEffectState>>(Channel(Channel.CONFLATED))
private val tunnelProviderFlow: StateFlow<TunnelProvider> = run { private val tunnelProviderFlow: StateFlow<TunnelProvider> = run {
val currentBackend = AtomicReference(userspaceTunnel) val currentBackend = AtomicReference(userspaceTunnel)
val currentSettings = AtomicReference(GeneralSettings()) val currentSettings = AtomicReference(GeneralSettings())
@@ -105,61 +104,9 @@ constructor(
override val activeTunnels: StateFlow<Map<Int, TunnelState>> = run { override val activeTunnels: StateFlow<Map<Int, TunnelState>> = run {
val activeTunsReference: AtomicReference<Map<Int, TunnelState>> = val activeTunsReference: AtomicReference<Map<Int, TunnelState>> =
AtomicReference(emptyMap()) AtomicReference(emptyMap())
tunnelProviderFlow tunnelProviderFlow
.flatMapLatest { backend -> .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( combine(
backend.activeTunnels, backend.activeTunnels,
tunnelsRepository.flow, tunnelsRepository.flow,
@@ -171,9 +118,67 @@ constructor(
.onStart { handleStateRestore() } .onStart { handleStateRestore() }
.onEach { (activeTuns, tuns, settings) -> .onEach { (activeTuns, tuns, settings) ->
val previouslyActive = activeTunsReference.exchange(activeTuns) val previouslyActive = activeTunsReference.exchange(activeTuns)
sideEffectChannelFlow.value.trySend( val state = SideEffectState(activeTuns, tuns, settings, previouslyActive)
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 } .map { (activeTuns, _, _) -> activeTuns }
.stateIn( .stateIn(
@@ -375,29 +380,39 @@ constructor(
activeTuns: Map<Int, TunnelState>, activeTuns: Map<Int, TunnelState>,
configs: List<TunnelConf>, configs: List<TunnelConf>,
settings: GeneralSettings, settings: GeneralSettings,
) { ) =
val activeIds = activeTuns.keys monitoringMutex.withLock {
val obsoleteIds = monitoringJobs.keys - activeIds val activeIds = activeTuns.keys.toSet()
obsoleteIds.forEach { id -> val currentJobs = monitoringJobs.keys.toSet()
monitoringJobs[id]?.cancel() val obsoleteIds = currentJobs - activeIds
monitoringJobs.remove(id)
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)
},
)
}
}
}
} }
@@ -139,6 +139,7 @@ constructor(
val updates = ConcurrentMap<Key, PingState>() val updates = ConcurrentMap<Key, PingState>()
pingablePeers.forEach { peer -> pingablePeers.forEach { peer ->
ensureActive()
val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState() val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState()
val allowedIpStr = peer.allowedIps.firstOrNull()?.toString() val allowedIpStr = peer.allowedIps.firstOrNull()?.toString()
@@ -172,41 +173,45 @@ constructor(
val attemptTime = System.currentTimeMillis() val attemptTime = System.currentTimeMillis()
runCatching { runCatching {
val pingStats = withTimeout(
settings.tunnelPingTimeoutSeconds?.let { settings.tunnelPingTimeoutSeconds?.toMillis() ?: 5000L
networkUtils.pingWithStats( ) {
host, val pingStats =
settings.tunnelPingAttempts, settings.tunnelPingTimeoutSeconds?.let {
it.toMillis(), networkUtils.pingWithStats(
) host,
} settings.tunnelPingAttempts,
?: networkUtils.pingWithStats( it.toMillis(),
host, )
settings.tunnelPingAttempts, }
) ?: networkUtils.pingWithStats(
host,
settings.tunnelPingAttempts,
)
updates[peer.publicKey] = updates[peer.publicKey] =
previousState.copy( previousState.copy(
transmitted = pingStats.transmitted, transmitted = pingStats.transmitted,
received = pingStats.received, received = pingStats.received,
packetLoss = pingStats.packetLoss, packetLoss = pingStats.packetLoss,
rttMin = pingStats.rttMin, rttMin = pingStats.rttMin,
rttMax = pingStats.rttMax, rttMax = pingStats.rttMax,
rttAvg = pingStats.rttAvg, rttAvg = pingStats.rttAvg,
rttStddev = pingStats.rttStddev, rttStddev = pingStats.rttStddev,
isReachable = pingStats.isReachable, isReachable = pingStats.isReachable,
failureReason = failureReason =
if (pingStats.isReachable) null if (pingStats.isReachable) null
else FailureReason.PingFailed, else FailureReason.PingFailed,
lastSuccessfulPingMillis = lastSuccessfulPingMillis =
pingStats.lastSuccessfulPingMillis pingStats.lastSuccessfulPingMillis
?: previousState.lastSuccessfulPingMillis, ?: previousState.lastSuccessfulPingMillis,
pingTarget = host, pingTarget = host,
lastPingAttemptMillis = attemptTime, 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 { .onFailure {
Timber.e( Timber.e(
@@ -224,6 +229,7 @@ constructor(
} }
if (updates.isNotEmpty()) { if (updates.isNotEmpty()) {
ensureActive()
pingStatsFlow.update { updates } pingStatsFlow.update { updates }
updateTunnelStatus(tunnelConf.id, null, null, updates, null) updateTunnelStatus(tunnelConf.id, null, null, updates, null)
} }
@@ -236,6 +242,7 @@ constructor(
delay(3_000L) delay(3_000L)
while (isActive) { while (isActive) {
ensureActive()
if (isNetworkConnected.value) { if (isNetworkConnected.value) {
performPing() performPing()
} else { } else {
@@ -248,6 +255,7 @@ constructor(
) )
} }
} }
ensureActive()
updateTunnelStatus(tunnelConf.id, null, null, pingStatsFlow.value, null) updateTunnelStatus(tunnelConf.id, null, null, pingStatsFlow.value, null)
} }
delay(settings.tunnelPingIntervalSeconds.toMillis()) delay(settings.tunnelPingIntervalSeconds.toMillis())
@@ -264,7 +272,9 @@ constructor(
) -> Unit, ) -> Unit,
) = coroutineScope { ) = coroutineScope {
while (isActive) { while (isActive) {
ensureActive()
val stats = getStatistics(tunnelId) val stats = getStatistics(tunnelId)
ensureActive()
updateTunnelStatus(tunnelId, null, stats, null, null) updateTunnelStatus(tunnelId, null, stats, null, null)
delay(STATS_DELAY) delay(STATS_DELAY)
} }
@@ -14,8 +14,6 @@ sealed class GlobalSideEffect {
data object PopBackStack : GlobalSideEffect() data object PopBackStack : GlobalSideEffect()
data class ShareFile(val file: File) : GlobalSideEffect()
data class LaunchUrl(val url: String) : GlobalSideEffect() data class LaunchUrl(val url: String) : GlobalSideEffect()
data object ConfigChanged : GlobalSideEffect() data object ConfigChanged : GlobalSideEffect()
@@ -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,
)
}
}
},
)
}
@@ -24,12 +24,18 @@ fun PinLockScreen() {
val pinAlreadyExists by rememberSaveable { mutableStateOf(PinManager.pinExists()) } val pinAlreadyExists by rememberSaveable { mutableStateOf(PinManager.pinExists()) }
var pinCreated by rememberSaveable { mutableStateOf(false) } var pinCreated by rememberSaveable { mutableStateOf(false) }
fun onPinCorrect() {
sharedViewModel.authenticated()
navController.popBackStack()
navController.navigate(Route.TunnelsGraph)
}
PinLock( PinLock(
title = { title = {
Text( Text(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
text = text =
if (pinAlreadyExists || pinCreated) { if (pinAlreadyExists) {
stringResource(id = R.string.enter_pin) stringResource(id = R.string.enter_pin)
} else { } else {
stringResource(id = R.string.create_pin) stringResource(id = R.string.create_pin)
@@ -38,11 +44,7 @@ fun PinLockScreen() {
}, },
backgroundColor = MaterialTheme.colorScheme.surface, backgroundColor = MaterialTheme.colorScheme.surface,
textColor = MaterialTheme.colorScheme.onSurface, textColor = MaterialTheme.colorScheme.onSurface,
onPinCorrect = { onPinCorrect = { onPinCorrect() },
sharedViewModel.authenticated()
navController.popBackStack()
navController.navigate(Route.TunnelsGraph)
},
onPinIncorrect = { onPinIncorrect = {
sharedViewModel.showToast(StringValue.StringResource(R.string.incorrect_pin)) sharedViewModel.showToast(StringValue.StringResource(R.string.incorrect_pin))
}, },
@@ -50,6 +52,7 @@ fun PinLockScreen() {
pinCreated = true pinCreated = true
sharedViewModel.showToast(StringValue.StringResource(R.string.pin_created)) sharedViewModel.showToast(StringValue.StringResource(R.string.pin_created))
sharedViewModel.setPinLockEnabled(true) sharedViewModel.setPinLockEnabled(true)
onPinCorrect()
}, },
) )
BackHandler(enabled = (!pinAlreadyExists && !pinCreated)) { BackHandler(enabled = (!pinAlreadyExists && !pinCreated)) {
@@ -32,7 +32,6 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asString import com.zaneschepke.wireguardautotunnel.util.extensions.asString
import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel
import org.orbitmvi.orbit.compose.collectSideEffect import org.orbitmvi.orbit.compose.collectSideEffect
import xyz.teamgravity.pin_lock_compose.PinManager
@Composable @Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) { fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
@@ -158,7 +157,6 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
add( add(
pinLockItem(settingsState.isPinLockEnabled) { enabled -> pinLockItem(settingsState.isPinLockEnabled) { enabled ->
if (enabled) { if (enabled) {
PinManager.initialize(context)
navController.navigate(Route.Lock) navController.navigate(Route.Lock)
} else { } else {
sharedViewModel.setPinLockEnabled(false) sharedViewModel.setPinLockEnabled(false)
@@ -69,7 +69,16 @@ fun LogsScreen(viewModel: LoggerViewModel = hiltViewModel()) {
} }
if (showLogsSheet) { if (showLogsSheet) {
LogsBottomSheet({ viewModel.exportLogs() }, { viewModel.deleteLogs() }) { LogsBottomSheet(
{ uri ->
viewModel.exportLogs(uri)
showLogsSheet = false
},
{
viewModel.deleteLogs()
showLogsSheet = false
},
) {
showLogsSheet = false showLogsSheet = false
} }
} }
@@ -1,24 +1,53 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.components package com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.components
import android.net.Uri
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R 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.CustomBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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( CustomBottomSheet(
listOf( listOf(
SheetOption( SheetOption(
Icons.Outlined.FolderZip, Icons.Outlined.FolderZip,
stringResource(R.string.export_logs), stringResource(R.string.export_logs),
onClick = onExport, onClick = { handleFileExport() },
), ),
SheetOption( SheetOption(
Icons.Outlined.Delete, Icons.Outlined.Delete,
@@ -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.functions.rememberFileExportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet
import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption 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.FileUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport import com.zaneschepke.wireguardautotunnel.util.extensions.hasSAFSupport
import java.time.Instant
@Composable @Composable
fun ExportTunnelsBottomSheet( fun ExportTunnelsBottomSheet(
@@ -37,7 +37,12 @@ fun ExportTunnelsBottomSheet(
fun handleFileExport() { fun handleFileExport() {
if (context.hasSAFSupport(FileUtils.ZIP_FILE_MIME_TYPE)) { 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 { } else {
onExport(exportConfigType, null) onExport(exportConfigType, null)
} }
@@ -1,16 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components
import android.util.Patterns import android.util.Patterns
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.material3.Text
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem 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 import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
@Composable @Composable
@@ -18,18 +38,73 @@ fun pingConfigItem(tunnelConf: TunnelConf, onSubmit: (ip: String) -> Unit): Sele
return SelectionItem( return SelectionItem(
title = {}, title = {},
description = { 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)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SubmitConfigurationTextBox( CustomTextField(
value = tunnelConf.pingTarget, isError = isError,
label = stringResource(R.string.set_custom_ping_target), textStyle =
hint = stringResource(R.string.ip_or_hostname), MaterialTheme.typography.bodySmall.copy(
isErrorValue = { color = MaterialTheme.colorScheme.onSurface
it?.isNotBlank() == true && ),
!it.isValidIpv4orIpv6Address() && value = stateValue,
!Patterns.DOMAIN_NAME.matcher(it).matches() 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)) }, 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 SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1 shl 10
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30 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 QR_CODE_NAME_PROPERTY = "# Name ="
const val FDROID_FLAVOR = "fdroid" const val FDROID_FLAVOR = "fdroid"
@@ -117,7 +117,7 @@ constructor(
} }
fun setTunnelOnEthernet(to: Boolean) = intent { fun setTunnelOnEthernet(to: Boolean) = intent {
settingsRepository.save(state.generalSettings.copy(isTunnelOnMobileDataEnabled = to)) settingsRepository.save(state.generalSettings.copy(isTunnelOnEthernetEnabled = to))
} }
fun setDebounceDelay(to: Int) = intent { fun setDebounceDelay(to: Int) = intent {
@@ -1,7 +1,9 @@
package com.zaneschepke.wireguardautotunnel.viewmodel package com.zaneschepke.wireguardautotunnel.viewmodel
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository 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.FileUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -71,18 +72,30 @@ constructor(
globalEffectRepository.post(globalSideEffect) globalEffectRepository.post(globalSideEffect)
} }
fun exportLogs() = intent { fun exportLogs(uri: Uri?) = intent {
val result = val result =
fileUtils.createNewShareFile( 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)) } val onFailure = { action: Throwable ->
result.onFailure { error -> Timber.e(action)
val message = intent {
error.message?.let { StringValue.DynamicString(it) } postSideEffect(
?: StringValue.StringResource(R.string.unknown_error) GlobalSideEffect.Toast(
postSideEffect(GlobalSideEffect.Toast(message)) 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 { fun deleteLogs() = intent {
+5 -4
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="files" path="." /> <files-path name="files" path="." />
<external-path name="external_files" path="." /> <external-path name="external_files" path="." />
<external-files-path name="apks" path="/" /> <external-files-path name="apks" path="/" />
<cache-path name="cache_files" path="external_files/" />
</paths> </paths>
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants { object Constants {
const val VERSION_NAME = "4.0.0" const val VERSION_NAME = "4.0.3"
const val VERSION_CODE = 40000 const val VERSION_CODE = 40003
const val TARGET_SDK = 36 const val TARGET_SDK = 36
const val MIN_SDK = 26 const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel" 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
+9 -9
View File
@@ -1,7 +1,7 @@
[versions] [versions]
accompanist = "0.37.3" accompanist = "0.37.3"
activityCompose = "1.11.0" activityCompose = "1.11.0"
amneziawgAndroid = "2.1.8" amneziawgAndroid = "2.1.9"
androidx-junit = "1.3.0" androidx-junit = "1.3.0"
icmp4a = "1.0.0" icmp4a = "1.0.0"
orbitCompose = "10.0.0" orbitCompose = "10.0.0"
@@ -12,28 +12,28 @@ coreKtx = "1.17.0"
datastorePreferences = "1.2.0-alpha02" datastorePreferences = "1.2.0-alpha02"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
espressoCore = "3.7.0" espressoCore = "3.7.0"
hiltAndroid = "2.57.1" hiltAndroid = "2.57.2"
hiltCompiler = "1.3.0" hiltCompiler = "1.3.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.9.0" kotlinx-serialization-json = "1.9.0"
ktorClientCore = "3.3.0" ktorClientCore = "3.3.0"
lifecycle-runtime-compose = "2.9.4" lifecycle-runtime-compose = "2.9.4"
material3 = "1.3.2" material3 = "1.4.0"
navigationCompose = "2.9.4" navigationCompose = "2.9.5"
pinLockCompose = "1.0.5" pinLockCompose = "1.0.5"
qrose = "1.0.1" qrose = "1.0.1"
roomVersion = "2.8.0" roomVersion = "2.8.1"
semver4j = "3.1.0" semver4j = "3.1.0"
slf4jAndroid = "1.7.36" slf4jAndroid = "1.7.36"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.4.0" tunnel = "1.4.0"
androidGradlePlugin = "8.12.0" androidGradlePlugin = "8.11.1"
kotlin = "2.2.20" kotlin = "2.2.20"
ksp = "2.2.20-2.0.3" ksp = "2.2.20-2.0.3"
composeBom = "2025.09.00" composeBom = "2025.09.01"
compose = "1.9.1" compose = "1.9.2"
icons = "1.7.8" icons = "1.7.8"
workRuntimeKtxVersion = "2.10.4" workRuntimeKtxVersion = "2.10.5"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1" coreSplashscreen = "1.0.1"
gradlePlugins-grgit = "5.3.3" gradlePlugins-grgit = "5.3.3"
@@ -8,7 +8,7 @@ interface LogReader {
fun stop() fun stop()
fun zipLogFiles(path: String) suspend fun zipLogFiles(path: String)
suspend fun deleteAndClearLogs() suspend fun deleteAndClearLogs()
@@ -1,5 +1,6 @@
package com.zaneschepke.logcatter package com.zaneschepke.logcatter
import android.R.attr.path
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.zaneschepke.logcatter.model.LogMessage import com.zaneschepke.logcatter.model.LogMessage
@@ -61,15 +62,13 @@ class LogcatManager(pid: Int, logDir: String, maxFileSize: Long, maxFolderSize:
isStarted = false isStarted = false
} }
override fun zipLogFiles(path: String) { override suspend fun zipLogFiles(path: String) {
logScope.launch { val wasStarted = isStarted
val wasStarted = isStarted stop()
stop() fileManager.zipLogs(path)
fileManager.zipLogs(path) if (wasStarted) {
if (wasStarted) { logcatReader.clearLogs()
logcatReader.clearLogs() start()
start()
}
} }
} }