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:
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),
|
||||||
|
|||||||
+12
-1
@@ -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
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user