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