Read Android log snapshots without losing queued updates

This commit is contained in:
evgeny committed 2026-10-28 12:28:28 +01:00
1 parent 38d24e7bbf
commit 401e65c132
3 files changed
+176 -34

No files matched your search

@@ -1,10 +1,10 @@
package com.utun.chat.data
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Одна строка диагностики; timestamp — локальное время приёма в миллисекундах, level — имя TRACE..ERROR. */
data class LogEntry(
val timestamp: Long = System.currentTimeMillis(),
val level: String,
@@ -12,31 +12,65 @@ data class LogEntry(
val message: String
)
/** Ограниченная история в порядке приёма, общая для всех потоков.
* revision уведомляет об изменении, а не переносит строки: медленный/новый подписчик читает актуальный getRecent().
* Ревизии могут объединяться StateFlow; строки сохраняются до вытеснения старейших по capacity.
*/
internal class LogHistory(private val capacity: Int) {
init { require(capacity > 0) { "Log history capacity must be positive: $capacity" } }
private val buffer = ArrayDeque<LogEntry>(capacity)
private val _revision = MutableStateFlow(0L)
val revision = _revision.asStateFlow()
@Synchronized
fun add(entry: LogEntry) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.add(entry)
_revision.value += 1
}
/** Независимый снимок последних count строк от старых к новым; отрицательный count вызывает IllegalArgumentException. */
@Synchronized
fun getRecent(count: Int): List<LogEntry> = buffer.takeLast(count)
}
/** Общий буфер последних 100000 строк Kotlin/JNI; состояние переживает Activity, при завершении процесса теряется.
* addLog потокобезопасен и пишет в logcat с указанной важностью, без native-фильтра категорий/доставки в файл или UDP.
* revision — объединяемое уведомление для чтения снимка getRecent(), поэтому задержка UI не теряет строки в очереди событий.
*/
object LogManager {
/** Категория video проходит общий native-фильтр и доставку в файл/UDP, как C-логи. */
fun videoDebug(message: String) { NativeLib.videoLog(false, message) }
fun videoTrace(message: String) { NativeLib.videoLog(true, message) }
private const val MAX_BUFFER = 100000
private val _buffer = ArrayDeque<LogEntry>(MAX_BUFFER)
private val _events = MutableSharedFlow<LogEntry>(replay = 0, extraBufferCapacity = 512)
val events: SharedFlow<LogEntry> = _events.asSharedFlow()
private val history = LogHistory(100000)
val revision = history.revision
@Synchronized
fun addLog(level: String, category: String, message: String) {
Log.d("utun-gui", "[$level] [$category] $message")
val entry = LogEntry(level = level, category = category, message = message)
if (_buffer.size >= MAX_BUFFER) _buffer.removeFirst()
_buffer.add(entry)
_events.tryEmit(entry)
val priority = when (level) {
"TRACE" -> Log.VERBOSE
"DEBUG" -> Log.DEBUG
"INFO" -> Log.INFO
"WARN" -> Log.WARN
"ERROR" -> Log.ERROR
else -> Log.DEBUG
}
Log.println(priority, "utun-gui", "[$level] [$category] $message")
history.add(LogEntry(level = level, category = category, message = message))
}
@Synchronized
/** Снимок последних count строк в порядке приёма; count >= 0, превышение размера истории возвращает всю историю. */
fun getRecent(count: Int = 100): List<LogEntry> {
return _buffer.takeLast(count)
if (count < 0) {
addLog("ERROR", "Log", "Invalid log history count=$count")
throw IllegalArgumentException("Log history count must be non-negative: $count")
}
return history.getRecent(count)
}
/** Called from JNI (via JNI config callback interface) */
/** JNI передаёт bridge-уровни 0=TRACE..4=ERROR, уже преобразованные из DEBUG_LEVEL_* и прошедшие native-фильтр.
* Неизвестный уровень записывается предупреждением с исходным значением; null category/message заменяются пустыми строками. */
fun onNativeLog(level: Int, category: String?, message: String?) {
val lvl = when (level) {
0 -> "TRACE"
@@ -44,7 +78,10 @@ object LogManager {
2 -> "INFO"
3 -> "WARN"
4 -> "ERROR"
else -> "NONE"
else -> {
addLog("WARN", "Log", "Unknown JNI log level=$level category=${category.orEmpty()} message=${message.orEmpty()}")
return
}
}
addLog(lvl, category ?: "", message ?: "")
}
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -17,33 +16,39 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.ChatApplication
import com.utun.chat.data.LogEntry
import com.utun.chat.data.LogManager
/** Последние 10000 строк общей истории, включая накопленные до открытия экрана; снимки обновляются не чаще кадра.
* Фильтр выбирает точный уровень. Пауза отключает только автопрокрутку, сбор логов продолжается.
* Подписка получает актуальную ревизию при открытии; прокрутка следует отфильтрованному списку и не блокирует чтение истории.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogScreen(onBack: () -> Unit) {
val entries = remember { mutableStateListOf<LogEntry>() }
var entries by remember { mutableStateOf(LogManager.getRecent(10000)) }
val listState = rememberLazyListState()
var filterLevel by remember { mutableStateOf("") }
var autoScroll by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
/* Load history from in-memory buffer */
val recent = LogManager.getRecent(500)
entries.addAll(recent)
/* Live tail via SharedFlow */
LogManager.events.collect { entry ->
entries.add(entry)
if (entries.size > 10000) entries.removeAt(0)
if (autoScroll) {
listState.animateScrollToItem(entries.size - 1)
LogManager.addLog("DEBUG", "Log", "Log viewer subscribed: history_limit=10000")
try {
LogManager.revision.collect {
withFrameNanos { }
entries = LogManager.getRecent(10000)
}
} finally {
LogManager.addLog("DEBUG", "Log", "Log viewer unsubscribed")
}
}
val filtered = remember(entries, filterLevel) {
if (filterLevel.isEmpty()) entries else entries.filter { it.level == filterLevel }
}
LaunchedEffect(filtered, autoScroll) {
if (autoScroll && filtered.isNotEmpty()) listState.scrollToItem(filtered.lastIndex)
}
Scaffold(
topBar = {
TopAppBar(
@@ -72,11 +77,8 @@ fun LogScreen(onBack: () -> Unit) {
)
}
) { padding ->
val filtered = if (filterLevel.isEmpty()) entries.toList()
else entries.filter { it.level == filterLevel }
LazyColumn(modifier = Modifier.padding(padding), state = listState) {
items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry
items(filtered.size) { idx ->
val entry = filtered[idx]
val color = when (entry.level) {
"ERROR" -> Color(0xFFFFB4AB)
@@ -0,0 +1,103 @@
package com.utun.chat.data
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.*
import org.junit.Test
/** Настоящая история экрана логов: переполнение, открытие/задержка подписчика и конкурентные производители. */
class LogHistoryTest {
private fun entry(message: String) = LogEntry(timestamp = 1, level = "INFO", category = "test", message = message)
@Test fun boundedHistoryKeepsLatestEntriesInReceptionOrder() {
val history = LogHistory(3)
repeat(5) { history.add(entry("$it")) }
assertEquals(listOf("2", "3", "4"), history.getRecent(10).map { it.message })
assertEquals(listOf("3", "4"), history.getRecent(2).map { it.message })
assertTrue(history.getRecent(0).isEmpty())
assertEquals(5L, history.revision.value)
}
@Test fun snapshotRemainsUnchangedWhileOldRowsAreEvicted() {
val history = LogHistory(2)
history.add(entry("before"))
val snapshot = history.getRecent(2)
repeat(20) { history.add(entry("after-$it")) }
assertEquals(listOf("before"), snapshot.map { it.message })
assertEquals(listOf("after-18", "after-19"), history.getRecent(2).map { it.message })
}
@Test fun identicalMessagesWithTheSameTimestampStillAdvanceRevision() {
val history = LogHistory(4)
val repeated = entry("same")
repeat(4) { history.add(repeated) }
assertEquals(4L, history.revision.value)
assertEquals(List(4) { repeated }, history.getRecent(4))
}
@Test fun subscriberOpeningAfterLastLogSeesHistoryWithoutAnotherMessage() = runBlocking {
val history = LogHistory(4)
history.add(entry("already buffered"))
assertEquals(1L, withTimeout(2000) { history.revision.first() })
assertEquals("already buffered", history.getRecent(1).single().message)
}
@Test fun stalledSubscriberRecoversBurstBeyondTheOld512EventQueue() = runBlocking {
val history = LogHistory(4096)
val resume = CompletableDeferred<Unit>()
val observed = CompletableDeferred<Pair<Long, List<LogEntry>>>()
val reader = launch(start = CoroutineStart.UNDISPATCHED) {
history.revision.collect { revision ->
if (revision == 0L) resume.await()
else observed.complete(revision to history.getRecent(4096))
}
}
try {
repeat(2048) { history.add(entry("$it")) }
resume.complete(Unit)
val (revision, snapshot) = withTimeout(2000) { observed.await() }
assertEquals(2048L, revision)
assertEquals((0 until 2048).map(Int::toString), snapshot.map { it.message })
} finally { reader.cancelAndJoin() }
}
@Test fun concurrentWritersPreserveEveryEntryAndTheirOwnOrder() {
val writers = 6
val count = 1024
val history = LogHistory(writers * count)
val start = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(writers)
try {
val tasks = (0 until writers).map { writer ->
executor.submit {
check(start.await(5, TimeUnit.SECONDS))
repeat(count) { history.add(entry("$writer:$it")) }
}
}
start.countDown()
tasks.forEach { it.get(5, TimeUnit.SECONDS) }
val messages = history.getRecent(writers * count).map { it.message }
assertEquals((writers * count).toLong(), history.revision.value)
assertEquals(writers * count, messages.toSet().size)
repeat(writers) { writer ->
assertEquals((0 until count).map { "$writer:$it" }, messages.filter { it.startsWith("$writer:") })
}
} finally { executor.shutdownNow() }
}
@Test fun invalidBoundsAreRejectedAndEmptyHistoryIsReadable() {
for (capacity in listOf(0, -1)) assertThrows(IllegalArgumentException::class.java) { LogHistory(capacity) }
val history = LogHistory(1)
assertTrue(history.getRecent(10).isEmpty())
assertEquals(0L, history.revision.value)
assertThrows(IllegalArgumentException::class.java) { history.getRecent(-1) }
}
}