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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:19:33 +07:00
1 parent ce80c36be1
commit 1b4fdc83d8
3 files changed
+105 -18

No files matched your search

@@ -35,13 +35,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -122,8 +127,15 @@ class LogViewModel : ViewModel()
private val _lines = MutableStateFlow<List<String>>(emptyList()) private val _lines = MutableStateFlow<List<String>>(emptyList())
val lines: StateFlow<List<String>> = _lines val lines: StateFlow<List<String>> = _lines
/* tail only while the screen is shown (set by LogScreen) */
private val active = MutableStateFlow(false)
private var started = false private var started = false
fun setActive(value: Boolean)
{
active.value = value
}
fun start(file: File) fun start(file: File)
{ {
if (started) return if (started) return
@@ -131,34 +143,64 @@ class LogViewModel : ViewModel()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val buffer = ArrayList<String>() val buffer = ArrayList<String>()
var reader: BufferedReader? = null var reader: BufferedReader? = null
try var lastLength = 0L
suspend fun flush()
{ {
reader = if (file.exists()) BufferedReader(FileReader(file)) else null if (buffer.isEmpty())
while (isActive)
{
val line = reader?.readLine()
if (line == null)
{
if (buffer.isNotEmpty())
{ {
return
}
val batch = buffer.toList() val batch = buffer.toList()
buffer.clear() buffer.clear()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch 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
} }
} }
delay(800) try
{
while (isActive)
{
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)
{
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
}
lastLength = length
delay(POLL_MS)
} }
else else
{ {
buffer.add(line) buffer.add(line)
if (buffer.size >= 50) if (buffer.size >= 50)
{ {
val batch = buffer.toList() flush()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
} }
} }
} }
@@ -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) private enum class LogLevelFilter(val labelRes: Int)
@@ -208,18 +256,43 @@ private fun LogScreen(
} }
} }
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE)) 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) { LaunchedEffect(messages) {
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) } 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) { LaunchedEffect(lines.size, filter) {
if (lines.isNotEmpty()) if (lines.isNotEmpty())
{
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
lines.size < followedSize)
{ {
listState.scrollToItem(lines.lastIndex) listState.scrollToItem(lines.lastIndex)
} }
} }
followedSize = lines.size
followedFilter = filter
}
SecondaryScaffold( SecondaryScaffold(
title = stringResource(R.string.log_title), title = stringResource(R.string.log_title),
@@ -46,6 +46,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private VpnProfileDataSource mDataSource; private VpnProfileDataSource mDataSource;
private VpnStateService mService; private VpnStateService mService;
private boolean mBound; private boolean mBound;
private VpnProfile mDefaultProfile;
private boolean mDefaultProfileLoaded;
private final ServiceConnection mServiceConnection = new ServiceConnection() private final ServiceConnection mServiceConnection = new ServiceConnection()
{ {
@Override @Override
@@ -101,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{ {
super.onStartListening(); super.onStartListening();
mListening = true; mListening = true;
/* the configured profile may have changed while the panel was closed */
mDefaultProfileLoaded = false;
if (mService != null) if (mService != null)
{ {
@@ -123,7 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private VpnProfile getProfile() 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 @Override
@@ -148,18 +148,21 @@ private fun WidgetContent(context: Context, snapshot: WidgetSnapshot)
{ {
val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error
val connecting = snapshot.state == VpnStateService.State.CONNECTING.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( val status = context.getString(
when when
{ {
connected -> R.string.status_protected connected -> R.string.status_protected
connecting -> R.string.status_connecting connecting -> R.string.status_connecting
disconnecting -> R.string.state_disconnecting
else -> R.string.status_unprotected 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 */ /* same entry point as the quick settings tile */
val intent = Intent(context, VpnProfileControlActivity::class.java) val intent = Intent(context, VpnProfileControlActivity::class.java)
if (connected || connecting) if (connected || connecting || disconnecting)
{ {
intent.action = VpnProfileControlActivity.DISCONNECT intent.action = VpnProfileControlActivity.DISCONNECT
} }