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:
1 parent
95bcf32c0b
commit
4d8ee1a916
3 files changed
+88
-3
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 {
|
||||
@@ -74,3 +84,16 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user