From 4d8ee1a916e9e2c34d46341a6550e1cf0fb64c2c Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Sat, 5 Sep 2026 09:46:10 +0300 Subject: [PATCH] Fix Logcat crashes on duplicate log lines (#6170) Logcat used the complete log text as a Compose lazy-list key after 258de2097. Identical timestamped messages and repeated stack frames are valid log output, but collide as keys and crash the main thread while browsing logs. Give each occurrence its own key in a small ViewModel-owned presentation model. Count oldest-first so prepending newer logs and filtering unrelated lines retain existing occurrence keys. Build the projection off the main thread and keep raw text, duplicates, ordering, copy and share behavior unchanged. Add six regression tests covering duplicate messages, repeated frames, filtering, refresh, empty input and key-like text. All 63 Play Store unit tests pass; the debug APK was checked on the API 37.1 emulator with duplicate-line scrolling, filtering, refresh, reopening and lossless export. The original failure is documented by the user-provided physical-device crash log. --- .../com/v2ray/ang/ui/logcat/LogcatActivity.kt | 6 +- .../v2ray/ang/ui/logcat/LogcatViewModel.kt | 25 +++++++- .../v2ray/ang/ui/logcat/LogcatEntryTest.kt | 62 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/ui/logcat/LogcatEntryTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatActivity.kt index ce4f9c7b..c88d39b6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatActivity.kt @@ -132,7 +132,7 @@ fun LogcatScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val logs by viewModel.filteredLogs.collectAsStateWithLifecycle() + val logs by viewModel.logEntries.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() var searchQuery by remember { mutableStateOf("") } @@ -215,8 +215,8 @@ fun LogcatScreen( .verticalScrollbar(listState), contentPadding = NavigationBarsBottomPadding() ) { - items(items = logs, key = { it }) { log -> - LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) }) + items(items = logs, key = { it.key }) { log -> + LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) }) ItemDivider() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatViewModel.kt index 1666456a..1898001f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/logcat/LogcatViewModel.kt @@ -1,14 +1,20 @@ package com.v2ray.ang.ui.logcat import android.app.Application +import androidx.lifecycle.viewModelScope import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.ui.base.BaseViewModel import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import java.io.IOException class LogcatViewModel(application: Application) : BaseViewModel(application) { @@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) { private val _filteredLogs = MutableStateFlow>(emptyList()) val filteredLogs: StateFlow> = _filteredLogs.asStateFlow() + val logEntries: StateFlow> = filteredLogs + .map(::createLogcatEntries) + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) fun loadLogcat() { launchLoading { @@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) { logsetsAll.filter { it.contains(currentFilter) } } } -} \ No newline at end of file +} + +data class LogcatEntry(val key: String, val text: String) + +internal fun createLogcatEntries(logs: List): List { + val occurrences = HashMap() + // Identical lines are valid. Count oldest-first so filtering other lines or + // prepending newer entries does not change the keys of existing occurrences. + return logs.asReversed().map { line -> + val occurrence = occurrences.getOrDefault(line, 0) + occurrences[line] = occurrence + 1 + LogcatEntry("$occurrence:$line", line) + }.asReversed() +} diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/ui/logcat/LogcatEntryTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/ui/logcat/LogcatEntryTest.kt new file mode 100644 index 00000000..e26f80c3 --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/ui/logcat/LogcatEntryTest.kt @@ -0,0 +1,62 @@ +package com.v2ray.ang.ui.logcat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogcatEntryTest { + @Test + fun emptyLogProducesNoEntries() { + assertTrue(createLogcatEntries(emptyList()).isEmpty()) + } + + @Test + fun identicalTimestampedLinesHaveUniqueKeysWithoutLosingMessages() { + val line = "08-31 04:32:41.187 I/GoLog (42): repeated message" + val logs = listOf(line, line, line) + val entries = createLogcatEntries(logs) + + assertEquals(logs, entries.map { it.text }) + assertEquals(3, entries.map { it.key }.distinct().size) + } + + @Test + fun preservesNewestFirstOrderAndOriginalText() { + val logs = listOf("newest", "\tat repeatedFrame(File.kt:42)", "middle", "\tat repeatedFrame(File.kt:42)") + val entries = createLogcatEntries(logs) + + assertEquals(logs, entries.map { it.text }) + assertEquals(logs.joinToString("\n"), entries.joinToString("\n") { it.text }) + assertEquals(logs.size, entries.map { it.key }.distinct().size) + } + + @Test + fun filteringOtherLinesPreservesKeysOfEveryMatchingOccurrence() { + val logs = listOf("match", "other", "match", "last") + val entries = createLogcatEntries(logs) + val filtered = createLogcatEntries(logs.filter { it.contains("match") }) + + assertEquals(entries.filter { it.text.contains("match") }, filtered) + assertTrue(createLogcatEntries(logs.filter { it.contains("missing") }).isEmpty()) + assertEquals(entries, createLogcatEntries(logs)) + } + + @Test + fun refreshWithNewerLinesPreservesExistingOccurrenceKeys() { + val logs = listOf("repeated", "older", "repeated") + val entries = createLogcatEntries(logs) + val refreshed = createLogcatEntries(listOf("newest", "repeated") + logs) + + assertEquals(entries, refreshed.takeLast(entries.size)) + assertEquals(refreshed.size, refreshed.map { it.key }.distinct().size) + } + + @Test + fun emptyAndKeyLikeMessagesRemainDistinct() { + val logs = listOf("", "0:", "", "1:message", "message", "message") + val entries = createLogcatEntries(logs) + + assertEquals(logs, entries.map { it.text }) + assertEquals(logs.size, entries.map { it.key }.distinct().size) + } +}