Compare commits

..

8 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
21 changed files with 263 additions and 176 deletions
@@ -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()
@@ -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)
} }
@@ -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 {
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants { object Constants {
const val VERSION_NAME = "4.0.1" const val VERSION_NAME = "4.0.3"
const val VERSION_CODE = 40001 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,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
+8 -8
View File
@@ -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()
}
} }
} }