Read Android log snapshots without losing queued updates
This commit is contained in:
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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user