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]
- 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
) {
@@ -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)
}
}
}
@@ -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) {
@@ -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)
},
)
}
}
}
}
@@ -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)
}
@@ -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()
@@ -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()) }
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)) {
@@ -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)
@@ -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
}
}
@@ -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,
@@ -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)
}
@@ -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"
@@ -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 {
+6 -5
View File
@@ -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>
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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()
}
}