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) + } +}