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.
This commit is contained in:
eliotcougar authored and GitHub committed 2026-09-05 14:46:10 +08:00
1 parent 95bcf32c0b
commit 4d8ee1a916
3 files changed
+89 -4

No files matched your search

@@ -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()
}
}
@@ -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<List<String>>(emptyList())
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
val logEntries: StateFlow<List<LogcatEntry>> = 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) }
}
}
}
}
data class LogcatEntry(val key: String, val text: String)
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
val occurrences = HashMap<String, Int>()
// 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()
}
@@ -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)
}
}