From 1b4fdc83d804bcfd3185bbf6ef195b69f41ff778 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 10 Oct 2026 09:19:33 +0700 Subject: [PATCH] android: tile/widget/log fixes - tile: query the configured profile once per listening session instead of on every state notification - widget: DISCONNECTING is shown as such, with a Disconnect button - log viewer: bounded to 5000 lines, tails only while visible, picks up a log created or restarted after opening, and no longer yanks the user back to the bottom while they scroll up Co-Authored-By: Claude Opus 5.5 --- .../org/strongswan/android/ui/LogActivity.kt | 105 +++++++++++++++--- .../strongswan/android/ui/VpnTileService.java | 13 ++- .../strongswan/android/ui/widget/ShxWidget.kt | 7 +- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt index ac0eeab3c..9f6bbe7a6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt @@ -35,13 +35,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -122,8 +127,15 @@ class LogViewModel : ViewModel() private val _lines = MutableStateFlow>(emptyList()) val lines: StateFlow> = _lines + /* tail only while the screen is shown (set by LogScreen) */ + private val active = MutableStateFlow(false) private var started = false + fun setActive(value: Boolean) + { + active.value = value + } + fun start(file: File) { if (started) return @@ -131,34 +143,64 @@ class LogViewModel : ViewModel() viewModelScope.launch(Dispatchers.IO) { val buffer = ArrayList() var reader: BufferedReader? = null + var lastLength = 0L + suspend fun flush() + { + if (buffer.isEmpty()) + { + return + } + val batch = buffer.toList() + buffer.clear() + withContext(Dispatchers.Main) { + val merged = _lines.value + batch + /* bounded: a long session must not grow the list forever */ + _lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged + } + } try { - reader = if (file.exists()) BufferedReader(FileReader(file)) else null while (isActive) { - val line = reader?.readLine() + if (!active.value) + { + active.first { it } + } + if (reader == null) + { /* the log may not exist yet (no connection so far) */ + if (file.exists()) + { + reader = BufferedReader(FileReader(file)) + lastLength = 0L + } + else + { + delay(POLL_MS) + continue + } + } + val line = reader.readLine() if (line == null) { - if (buffer.isNotEmpty()) - { - val batch = buffer.toList() - buffer.clear() - withContext(Dispatchers.Main) { - _lines.value = _lines.value + batch - } + flush() + val length = if (file.exists()) file.length() else -1L + if (length < lastLength) + { /* deleted or truncated (a new connection starts a fresh log): + * the reader would stay on the old file forever */ + try { reader.close() } catch (_: Exception) {} + reader = null + withContext(Dispatchers.Main) { _lines.value = emptyList() } + continue } - delay(800) + lastLength = length + delay(POLL_MS) } else { buffer.add(line) if (buffer.size >= 50) { - val batch = buffer.toList() - buffer.clear() - withContext(Dispatchers.Main) { - _lines.value = _lines.value + batch - } + flush() } } } @@ -172,6 +214,12 @@ class LogViewModel : ViewModel() } } } + + private companion object + { + const val MAX_LINES = 5000 + const val POLL_MS = 800L + } } private enum class LogLevelFilter(val labelRes: Int) @@ -208,17 +256,42 @@ private fun LogScreen( } } + val lifecycle = LocalLifecycleOwner.current.lifecycle LaunchedEffect(Unit) { viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE)) } + LaunchedEffect(lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.setActive(true) + try + { + awaitCancellation() + } + finally + { + viewModel.setActive(false) + } + } + } LaunchedEffect(messages) { messages.collect { snackbarHostState.showSnackbar(context.getString(it)) } } + /* follow the tail only while the user is at the bottom (or switched the + * filter): scrolling up to read must not be undone by every new batch */ + var followedSize by remember { mutableStateOf(0) } + var followedFilter by remember { mutableStateOf(filter) } LaunchedEffect(lines.size, filter) { if (lines.isNotEmpty()) { - listState.scrollToItem(lines.lastIndex) + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 || + lines.size < followedSize) + { + listState.scrollToItem(lines.lastIndex) + } } + followedSize = lines.size + followedFilter = filter } SecondaryScaffold( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnTileService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnTileService.java index 10852e539..b45e21424 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnTileService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnTileService.java @@ -46,6 +46,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt private VpnProfileDataSource mDataSource; private VpnStateService mService; private boolean mBound; + private VpnProfile mDefaultProfile; + private boolean mDefaultProfileLoaded; private final ServiceConnection mServiceConnection = new ServiceConnection() { @Override @@ -101,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt { super.onStartListening(); mListening = true; + /* the configured profile may have changed while the panel was closed */ + mDefaultProfileLoaded = false; if (mService != null) { @@ -123,7 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt private VpnProfile getProfile() { - return mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null; + /* updateTile() runs on every state notification (including retry + * countdown ticks): query the database once per listening session */ + if (!mDefaultProfileLoaded) + { + mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null; + mDefaultProfileLoaded = true; + } + return mDefaultProfile; } @Override diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt index 4f9209594..b02b6d60c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/widget/ShxWidget.kt @@ -148,18 +148,21 @@ private fun WidgetContent(context: Context, snapshot: WidgetSnapshot) { val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error val connecting = snapshot.state == VpnStateService.State.CONNECTING.name && !snapshot.error + /* not "Unprotected" with a Connect button while the tunnel is going down */ + val disconnecting = snapshot.state == VpnStateService.State.DISCONNECTING.name && !snapshot.error val status = context.getString( when { connected -> R.string.status_protected connecting -> R.string.status_connecting + disconnecting -> R.string.state_disconnecting else -> R.string.status_unprotected } ) - val buttonLabel = context.getString(if (connected || connecting) R.string.disconnect else R.string.connect) + val buttonLabel = context.getString(if (connected || connecting || disconnecting) R.string.disconnect else R.string.connect) /* same entry point as the quick settings tile */ val intent = Intent(context, VpnProfileControlActivity::class.java) - if (connected || connecting) + if (connected || connecting || disconnecting) { intent.action = VpnProfileControlActivity.DISCONNECT }