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 <[email protected]>
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
+106 -19

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.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<List<String>>(emptyList())
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
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<String>()
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(
@@ -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
@@ -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
}