diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilter.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilter.kt new file mode 100644 index 0000000000..717cbeb59e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilter.kt @@ -0,0 +1,245 @@ +package com.ai.assistance.operit.ui.features.toolbox.screens.logcat + +/** + * Parses AppLogger lines and applies export-only filters. + * Does not change how logs are written. + */ +object LogExportFilter { + private val headerRegex = + Regex("^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\s+([VDIWEAF])/(.*?): (.*)$") + + private val systemTags = setOf( + "androidruntime", + "system", + "system.err", + "system.out", + "webview", + "chromium", + "chromiumnet", + "okhttp", + "okhttpclient", + "libc", + "dalvikvm", + "art", + "activitymanager", + "packagemanager", + "windowmanager" + ) + + private val sensitiveHeaderPrefixes = listOf( + "发现系统消息:", + "请求体json:", + "request body json:", + "request body:", + "json del cuerpo de la solicitud:", + "json badan permintaan:", + "요청 본문 json:", + "corpo da solicitação json:", + "corpul cereriijson:", + "final deepseek reasoning mode request body:", + "final kimi k2.5 request body:", + "claude请求体:", + "final prompt before llama generation:" + ) + + private val sensitiveContinuationPrefixes = listOf( + "part " + ) + + private val sensitiveBodyTokens = listOf( + "\"systeminstruction\"", + "\"character_setting\"", + "\"charactersetting\"", + "\"advanced_custom_prompt\"", + "\"tag_prompt\"" + ) + + data class ExportRecord( + val timestamp: String?, + val level: Char?, + val tag: String?, + val message: String, + val continuationLines: List = emptyList() + ) { + val isHeader: Boolean get() = timestamp != null && level != null + + fun headerLine(): String { + if (!isHeader) return message + return "$timestamp $level/$tag: $message" + } + + fun allLines(): List { + return if (isHeader) listOf(headerLine()) + continuationLines else listOf(message) + continuationLines + } + } + + data class FilterResult( + val lines: List, + val originalRecordCount: Int, + val exportedRecordCount: Int + ) + + fun parseRecords(rawLines: Sequence): List { + val records = mutableListOf() + var current: ExportRecord? = null + + fun flush() { + val record = current ?: return + records += record + current = null + } + + for (raw in rawLines) { + val line = raw.trimEnd('\r') + if (line.isBlank()) continue + val match = headerRegex.matchEntire(line) + if (match != null) { + flush() + val (timestamp, level, tag, message) = match.destructured + current = ExportRecord( + timestamp = timestamp, + level = level[0], + tag = tag.trim(), + message = message + ) + } else { + val existing = current + if (existing != null) { + current = existing.copy(continuationLines = existing.continuationLines + line) + } else { + records += ExportRecord( + timestamp = null, + level = null, + tag = null, + message = line + ) + } + } + } + flush() + return records + } + + fun filter(rawLines: Sequence, options: LogExportOptions): FilterResult { + val parsed = parseRecords(rawLines) + if (options.isIdentity) { + return FilterResult( + lines = parsed.flatMap { it.allLines() }, + originalRecordCount = parsed.size, + exportedRecordCount = parsed.size + ) + } + + var working = parsed + if (options.excludeDebug) { + working = working.filter { record -> + val level = record.level ?: return@filter true + level != 'V' && level != 'D' + } + } + if (options.excludeSystem) { + working = working.filter { record -> + val tag = record.tag ?: return@filter true + tag.lowercase() !in systemTags + } + } + if (options.hideSensitive) { + working = working.map { redactSensitive(it) } + } + if (options.errorContextOnly) { + working = keepErrorContext(working) + } + val lines = working.flatMap { record -> + formatRecord(record, options.stripTimestamp) + } + return FilterResult( + lines = lines, + originalRecordCount = parsed.size, + exportedRecordCount = working.size + ) + } + + fun filterText(rawText: String, options: LogExportOptions): FilterResult { + return filter(rawText.lineSequence(), options) + } + + private fun keepErrorContext(records: List): List { + if (records.isEmpty()) return emptyList() + val lastErrorIndex = records.indexOfLast { isErrorLevel(it.level) } + if (lastErrorIndex < 0) return emptyList() + val prefixCount = (records.size / 10).coerceAtLeast(0) + val start = (lastErrorIndex - prefixCount).coerceAtLeast(0) + return records.subList(start, records.size) + } + + private fun isErrorLevel(level: Char?): Boolean { + return level == 'E' || level == 'A' || level == 'F' + } + + private fun redactSensitive(record: ExportRecord): ExportRecord { + val headerSensitive = isSensitiveHeader(record) + val redactedMessage = if (headerSensitive) { + redactPayload(record.message) + } else { + record.message + } + val redactedContinuations = record.continuationLines.map { line -> + if (headerSensitive || isSensitiveContinuation(line) || containsSensitiveToken(line)) { + redactPayload(line) + } else { + line + } + } + return record.copy(message = redactedMessage, continuationLines = redactedContinuations) + } + + private fun isSensitiveHeader(record: ExportRecord): Boolean { + val message = record.message.lowercase() + if (sensitiveHeaderPrefixes.any { message.startsWith(it) }) return true + if (containsSensitiveToken(record.message)) return true + return record.continuationLines.any { containsSensitiveToken(it) } + } + + private fun isSensitiveContinuation(line: String): Boolean { + val lower = line.trimStart().lowercase() + return sensitiveContinuationPrefixes.any { lower.startsWith(it) } || + Regex("^part\\s+\\d+/\\d+:", RegexOption.IGNORE_CASE).containsMatchIn(lower) + } + + private fun containsSensitiveToken(text: String): Boolean { + val lower = text.lowercase() + return sensitiveBodyTokens.any { it in lower } + } + + private fun redactPayload(text: String): String { + val marker = findRedactMarker(text) + val prefix = if (marker != null) { + val index = text.indexOf(marker, ignoreCase = true) + if (index >= 0) text.substring(0, index + marker.length).trimEnd() else text.substringBefore(':').let { if (it == text) "" else "$it:" } + } else { + text.substringBefore(':').let { if (it == text) "" else "$it:" } + } + val hiddenChars = (text.length - prefix.length).coerceAtLeast(0) + val label = "[redacted, $hiddenChars chars]" + return if (prefix.isBlank()) label else "$prefix $label" + } + + private fun findRedactMarker(text: String): String? { + val lower = text.lowercase() + return sensitiveHeaderPrefixes.firstOrNull { lower.startsWith(it) }?.let { marker -> + text.substring(0, marker.length) + } + } + + private fun formatRecord(record: ExportRecord, stripTimestamp: Boolean): List { + if (!record.isHeader) { + return listOf(record.message) + record.continuationLines + } + val header = if (stripTimestamp) { + "${record.level}/${record.tag}: ${record.message}" + } else { + record.headerLine() + } + return listOf(header) + record.continuationLines + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportOptions.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportOptions.kt new file mode 100644 index 0000000000..b21b2823a1 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportOptions.kt @@ -0,0 +1,20 @@ +package com.ai.assistance.operit.ui.features.toolbox.screens.logcat + +/** + * Export-time filters for AppLogger files. + * All flags default to false so the exported file stays complete. + */ +data class LogExportOptions( + val excludeDebug: Boolean = false, + val excludeSystem: Boolean = false, + val errorContextOnly: Boolean = false, + val hideSensitive: Boolean = false, + val stripTimestamp: Boolean = false +) { + val isIdentity: Boolean + get() = !excludeDebug && + !excludeSystem && + !errorContextOnly && + !hideSensitive && + !stripTimestamp +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportPreferences.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportPreferences.kt new file mode 100644 index 0000000000..d07d8a2bb7 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportPreferences.kt @@ -0,0 +1,36 @@ +package com.ai.assistance.operit.ui.features.toolbox.screens.logcat + +import android.content.Context + +class LogExportPreferences(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): LogExportOptions { + return LogExportOptions( + excludeDebug = prefs.getBoolean(KEY_EXCLUDE_DEBUG, false), + excludeSystem = prefs.getBoolean(KEY_EXCLUDE_SYSTEM, false), + errorContextOnly = prefs.getBoolean(KEY_ERROR_CONTEXT, false), + hideSensitive = prefs.getBoolean(KEY_HIDE_SENSITIVE, false), + stripTimestamp = prefs.getBoolean(KEY_STRIP_TIMESTAMP, false) + ) + } + + fun save(options: LogExportOptions) { + prefs.edit() + .putBoolean(KEY_EXCLUDE_DEBUG, options.excludeDebug) + .putBoolean(KEY_EXCLUDE_SYSTEM, options.excludeSystem) + .putBoolean(KEY_ERROR_CONTEXT, options.errorContextOnly) + .putBoolean(KEY_HIDE_SENSITIVE, options.hideSensitive) + .putBoolean(KEY_STRIP_TIMESTAMP, options.stripTimestamp) + .apply() + } + + companion object { + private const val PREFS_NAME = "log_export_preferences" + private const val KEY_EXCLUDE_DEBUG = "exclude_debug" + private const val KEY_EXCLUDE_SYSTEM = "exclude_system" + private const val KEY_ERROR_CONTEXT = "error_context_only" + private const val KEY_HIDE_SENSITIVE = "hide_sensitive" + private const val KEY_STRIP_TIMESTAMP = "strip_timestamp" + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt index a997ba5299..3a9da5c688 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt @@ -22,8 +22,10 @@ data class LogcatExportResult( ) object LogcatExportHelper { - - suspend fun exportLogs(context: Context): LogcatExportResult = withContext(Dispatchers.IO) { + suspend fun exportLogs( + context: Context, + options: LogExportOptions = LogExportOptions() + ): LogcatExportResult = withContext(Dispatchers.IO) { try { val logFile = AppLogger.getLogFile() if (logFile == null || !logFile.exists() || logFile.length() == 0L) { @@ -33,10 +35,15 @@ object LogcatExportHelper { ) } - val logLineCount = countExportableLogLines(logFile) - if (logLineCount == 0L) { + val exportLines = prepareExportLines(logFile, options) + if (exportLines == null) { + val emptyMessage = if (options.errorContextOnly) { + context.getString(R.string.logcat_no_error_logs) + } else { + context.getString(R.string.logcat_no_logs_to_save) + } return@withContext LogcatExportResult( - message = context.getString(R.string.logcat_no_logs_to_save), + message = emptyMessage, success = false ) } @@ -44,9 +51,9 @@ object LogcatExportHelper { val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val fileName = "operit_log_$timestamp.txt" val filePath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveUsingMediaStore(context, fileName, logFile, logLineCount) + saveUsingMediaStore(context, fileName, exportLines) } else { - saveUsingFileSystem(context, fileName, logFile, logLineCount) + saveUsingFileSystem(context, fileName, exportLines) } LogcatExportResult( @@ -64,37 +71,40 @@ object LogcatExportHelper { } } - private fun countExportableLogLines(logFile: File): Long { - var count = 0L - logFile.bufferedReader().useLines { lines -> - lines.forEach { line -> - if (line.isNotBlank()) { - count++ - } + private fun prepareExportLines( + logFile: File, + options: LogExportOptions + ): PreparedExport? { + logFile.bufferedReader().use { reader -> + if (options.isIdentity) { + val lines = reader.lineSequence().map { it.trimEnd('\r') }.filter { it.isNotBlank() }.toList() + if (lines.isEmpty()) return null + return PreparedExport(lines = lines, recordCount = lines.size.toLong()) } + val result = LogExportFilter.filter(reader.lineSequence(), options) + if (result.exportedRecordCount == 0 || result.lines.isEmpty()) { + return null + } + return PreparedExport( + lines = result.lines, + recordCount = result.exportedRecordCount.toLong() + ) } - return count } private fun writeLogContent( context: Context, writer: Writer, - logFile: File, - logLineCount: Long + export: PreparedExport ) { val exportTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) writer.appendLine(context.getString(R.string.logcat_header)) writer.appendLine(context.getString(R.string.logcat_date, exportTime)) - writer.appendLine(context.getString(R.string.logcat_total_count, logLineCount)) + writer.appendLine(context.getString(R.string.logcat_total_count, export.recordCount)) writer.appendLine("===================================") writer.appendLine() - - logFile.bufferedReader().useLines { lines -> - lines.forEach { line -> - if (line.isNotBlank()) { - writer.appendLine(line) - } - } + export.lines.forEach { line -> + writer.appendLine(line) } } @@ -102,8 +112,7 @@ object LogcatExportHelper { private fun saveUsingMediaStore( context: Context, fileName: String, - logFile: File, - logLineCount: Long + export: PreparedExport ): String { try { val contentValues = ContentValues().apply { @@ -118,23 +127,21 @@ object LogcatExportHelper { context.contentResolver.openOutputStream(uri)?.use { outputStream -> outputStream.bufferedWriter().use { writer -> - writeLogContent(context, writer, logFile, logLineCount) + writeLogContent(context, writer, export) } } ?: throw Exception(context.getString(R.string.logcat_cannot_open_output_stream)) - val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) return "${downloadsDir.absolutePath}/operit/$fileName" } catch (e: Exception) { - throw Exception(context.getString(R.string.logcat_mediestore_save_failed, e.message ?: "")) + throw Exception(context.getString(R.string.logcat_mediastore_save_failed, e.message ?: "")) } } private fun saveUsingFileSystem( context: Context, fileName: String, - logFile: File, - logLineCount: Long + export: PreparedExport ): String { try { val downloadsDir = @@ -148,7 +155,7 @@ object LogcatExportHelper { } val file = File(operitDir, fileName) FileWriter(file).use { writer -> - writeLogContent(context, writer, logFile, logLineCount) + writeLogContent(context, writer, export) } if (!file.exists() || file.length() == 0L) { throw Exception(context.getString(R.string.logcat_file_create_failed)) @@ -158,4 +165,9 @@ object LogcatExportHelper { throw Exception(context.getString(R.string.logcat_filesystem_save_failed, e.message ?: "")) } } + + private data class PreparedExport( + val lines: List, + val recordCount: Long + ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatScreen.kt index d44cc2df40..ff067f9f65 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatScreen.kt @@ -1,32 +1,56 @@ package com.ai.assistance.operit.ui.features.toolbox.screens.logcat -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.ai.assistance.operit.R -import com.ai.assistance.operit.ui.components.CustomScaffold import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.ai.assistance.operit.R +import com.ai.assistance.operit.ui.components.CustomScaffold -/** - * 应用日志导出屏幕 - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun LogcatScreen(navController: NavController? = null) { val context = LocalContext.current val viewModel: LogcatViewModel = viewModel(factory = LogcatViewModel.Factory(context)) - val isSaving by viewModel.isSaving.collectAsState() val saveResult by viewModel.saveResult.collectAsState() + val exportOptions by viewModel.exportOptions.collectAsState() CustomScaffold( topBar = { @@ -40,12 +64,17 @@ fun LogcatScreen(navController: NavController? = null) { } } ) { paddingValues -> - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), + modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(4.dp) ) { Column( @@ -68,7 +97,7 @@ fun LogcatScreen(navController: NavController? = null) { style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) Button( onClick = { viewModel.saveLogsToFile() }, enabled = !isSaving, @@ -86,7 +115,9 @@ fun LogcatScreen(navController: NavController? = null) { onClick = { viewModel.clearLogs() }, enabled = !isSaving, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) ) { Icon(Icons.Default.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) @@ -94,6 +125,105 @@ fun LogcatScreen(navController: NavController? = null) { } } } + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.logcat_export_filters_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.logcat_export_filters_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + LogExportFilterToggle( + title = stringResource(R.string.logcat_filter_exclude_debug), + subtitle = stringResource(R.string.logcat_filter_exclude_debug_desc), + checked = exportOptions.excludeDebug, + enabled = !isSaving, + onCheckedChange = { checked -> + viewModel.updateExportOptions { it.copy(excludeDebug = checked) } + } + ) + LogExportFilterToggle( + title = stringResource(R.string.logcat_filter_exclude_system), + subtitle = stringResource(R.string.logcat_filter_exclude_system_desc), + checked = exportOptions.excludeSystem, + enabled = !isSaving, + onCheckedChange = { checked -> + viewModel.updateExportOptions { it.copy(excludeSystem = checked) } + } + ) + LogExportFilterToggle( + title = stringResource(R.string.logcat_filter_error_context), + subtitle = stringResource(R.string.logcat_filter_error_context_desc), + checked = exportOptions.errorContextOnly, + enabled = !isSaving, + onCheckedChange = { checked -> + viewModel.updateExportOptions { it.copy(errorContextOnly = checked) } + } + ) + LogExportFilterToggle( + title = stringResource(R.string.logcat_filter_hide_sensitive), + subtitle = stringResource(R.string.logcat_filter_hide_sensitive_desc), + checked = exportOptions.hideSensitive, + enabled = !isSaving, + onCheckedChange = { checked -> + viewModel.updateExportOptions { it.copy(hideSensitive = checked) } + } + ) + LogExportFilterToggle( + title = stringResource(R.string.logcat_filter_strip_timestamp), + subtitle = stringResource(R.string.logcat_filter_strip_timestamp_desc), + checked = exportOptions.stripTimestamp, + enabled = !isSaving, + onCheckedChange = { checked -> + viewModel.updateExportOptions { it.copy(stripTimestamp = checked) } + } + ) + } + } + } + } +} + +@Composable +private fun LogExportFilterToggle( + title: String, + subtitle: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatViewModel.kt index 9348596321..dbbfcb1d17 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatViewModel.kt @@ -11,12 +11,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * 日志查看器ViewModel - 使用AppLogger文件 - */ class LogcatViewModel(private val context: Context) : ViewModel() { private val logcatManager = LogcatManager(context) - + private val exportPreferences = LogExportPreferences(context) private val _isSaving = MutableStateFlow(false) val isSaving: StateFlow = _isSaving.asStateFlow() @@ -24,21 +21,27 @@ class LogcatViewModel(private val context: Context) : ViewModel() { private val _saveResult = MutableStateFlow(null) val saveResult: StateFlow = _saveResult.asStateFlow() - + private val _exportOptions = MutableStateFlow(exportPreferences.load()) + val exportOptions: StateFlow = _exportOptions.asStateFlow() fun clearLogs() { logcatManager.clearLogs() } + fun updateExportOptions(transform: (LogExportOptions) -> LogExportOptions) { + val updated = transform(_exportOptions.value) + _exportOptions.value = updated + exportPreferences.save(updated) + } + fun saveLogsToFile() { if (_isSaving.value) return - _isSaving.value = true _saveResult.value = null - + val options = _exportOptions.value viewModelScope.launch { try { - val result = LogcatExportHelper.exportLogs(context) + val result = LogcatExportHelper.exportLogs(context, options) _saveResult.value = result.message delay(3000) _saveResult.value = null @@ -64,9 +67,4 @@ class LogcatViewModel(private val context: Context) : ViewModel() { throw IllegalArgumentException("Unknown ViewModel class") } } - - override fun onCleared() { - super.onCleared() - // No-op, no more monitoring to stop - } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ff8574452c..9a068700df 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -2594,6 +2594,18 @@ You can export all internal logs during app runtime to a file for debugging and issue analysis. Save logs to file Clear all logs + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file Speech Recognition @@ -5586,6 +5598,7 @@ No logs to save + No error logs to export === Operit Logs === Date: %1$s Total count: %1$d @@ -5594,7 +5607,7 @@ Logs saved to: %1$s Save failed: Unable to create file, possibly due to storage permissions Cannot open output stream - MediaStore save failed: %1$s + MediaStore save failed: %1$s Cannot create download directory Cannot create operit directory File creation failed or is empty diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a61e889ba0..39fc947817 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2793,6 +2793,18 @@ Puede exportar todos los registros internos de la aplicación durante su ejecución a un archivo para depuración y análisis de problemas. Guardar registros en archivo Limpiar todos los registros + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file Reconocimiento de voz Iniciar grabación Detener grabación @@ -5124,6 +5136,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. nombre del paquete: %1$s tamaño: %1$d×%2$dpx no hay registros para guardar + No error logs to export === Registros de Operit === fecha: %1$s total: %1$d @@ -5132,7 +5145,7 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. registros guardados en: %1$s error al guardar: no se pudo crear el archivo, posible problema de permisos de almacenamiento no se pudo abrir el flujo de salida - error al guardar en MediaStore: %1$s + error al guardar en MediaStore: %1$s no se pudo crear el directorio de descargas no se pudo crear el directorio operit no se pudo crear el archivo o está vacío diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 5dd7b0ed85..1031fffc98 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -2334,6 +2334,18 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Anda dapat mengekspor semua log internal selama aplikasi berjalan ke dalam satu berkas, untuk keperluan debug dan analisis masalah. Simpan Log ke Berkas Hapus Semua Log + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file Pengenalan Suara Mulai Rekam Hentikan Rekam @@ -4806,6 +4818,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Nama paket: %1$s Ukuran: %1$d×%2$dpx Tidak ada log yang dapat disimpan + No error logs to export === Log Operit === Tanggal: %1$s Jumlah total: %1$d @@ -4979,7 +4992,7 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Menghitung file... Menghitung file: %1$d - MediaStore gagal menyimpan: %1$s + MediaStore gagal menyimpan: %1$s Tidak dapat membuat direktori unduhan Tidak dapat membuat direktori operit Gagal membuat file atau file kosong diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 26ad5b6793..3e449e5395 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2322,6 +2322,18 @@ 디버깅 및 문제 분석을 위해 앱 런타임 중 모든 내부 로그를 파일로 내보낼 수 있습니다. 로그를 파일에 저장 모든 로그 지우기 + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file 음성 인식 녹음 시작 녹음 중지 @@ -5084,6 +5096,7 @@ 패키지: %1$s 크기: %1$d×%2$dpx 저장할 로그가 없습니다. + No error logs to export === Operit 로그 === 날짜: %1$s 총 개수: %1$d @@ -5092,7 +5105,7 @@ 로그 저장 위치: %1$s 저장 실패: 저장 권한으로 인해 파일을 생성할 수 없습니다. 출력 스트림을 열 수 없습니다. - MediaStore 저장 실패: %1$s + MediaStore 저장 실패: %1$s 다운로드 디렉터리를 생성할 수 없습니다. operit 디렉터리를 생성할 수 없습니다. 파일 생성에 실패했거나 비어 있습니다. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 38062bb6ed..d0f58a9725 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2288,6 +2288,18 @@ Anda boleh mengeksport semua log dalaman semasa aplikasi berjalan ke dalam fail untuk penyahpepijatan dan analisis masalah. Simpan Log ke Fail Kosongkan Semua Log + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file Pengecaman Suara Mula Rakaman Hentikan Rakaman @@ -4969,6 +4981,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Saiz: %1$d×%2$dpx Tiada log untuk disimpan + No error logs to export === Log Operit === Tarikh: %1$s Jumlah: %1$d @@ -4977,7 +4990,7 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Log telah disimpan ke: %1$s Gagal menyimpan: Tidak dapat mencipta fail, mungkin masalah kebenaran storan Tidak dapat membuka aliran output - MediaStore gagal menyimpan: %1$s + MediaStore gagal menyimpan: %1$s Tidak dapat mencipta direktori muat turun Tidak dapat mencipta direktori operit Penciptaan fail gagal atau kosong diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ee6a6df2bc..90cf931790 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2277,6 +2277,18 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Você pode exportar todos os logs internos durante a execução do aplicativo para um arquivo para depuração e análise de problemas. Salvar registro em arquivo Limpar todos os registros + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file reconhecimento de fala Comece a gravar Pare de gravar @@ -4667,6 +4679,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Nome do pacote: %1$s Tamanho: %1$d×%2$dpx Não há registros para salvar + No error logs to export === Log de operações === Data: %1$s Número total de itens: %1$d @@ -4675,7 +4688,7 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Registro salvo em: %1$s Falha ao salvar: não é possível criar o arquivo, possivelmente problema de permissão de armazenamento Não foi possível abrir o fluxo de saída - Falha ao salvar no MediaStore: %1$s + Falha ao salvar no MediaStore: %1$s Não foi possível criar o diretório de download Não foi possível criar o diretório operacional A criação do arquivo falhou ou está vazia diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 1808a6e65d..6c8522c54e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2584,6 +2584,18 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Puteți exporta toate jurnalele interne generate în timpul rulării aplicației într-un fișier, în scopul depanării și al analizei problemelor. Salvarea jurnalului într-un fișier Șterge toate jurnalele + Export filters + All filters are off by default and export the full log. They only affect the exported file. + Exclude debug logs + Drop Verbose and Debug levels + Exclude system logs + Drop noisy system tags such as WebView and OkHttp + Error context only + Keep the last error and the 10% of records above it + Hide sensitive content + Redact prompts and request-body dumps + Omit timestamps + Remove timestamps from the exported file Recunoașterea vocală @@ -5822,6 +5834,7 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model. Nu există jurnale care pot fi salvate + No error logs to export === Operit Jurnal === Data: %1$s Numărul total de articole: %1$d @@ -5830,7 +5843,7 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Jurnalul a fost salvat în:%1$s Eroare la salvare: nu se poate crea fișierul; este posibil să fie o problemă legată de permisiunile de stocare Nu se poate deschide fluxul de ieșire - MediaStoreEroare la salvare:%1$s + MediaStoreEroare la salvare:%1$s Nu se poate crea directorul de descărcări Nu se poate creaoperitCuprins Fișierul nu a putut fi creat sau este gol diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7089cceeed..baa6bc6653 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2648,6 +2648,18 @@ 您可以将应用运行期间的所有内部日志导出到一个文件,用于调试和问题分析。 保存日志到文件 清除所有日志 + 导出过滤 + 默认全部关闭,导出完整日志。仅影响导出文件,不改变写入。 + 排除调试日志 + 去掉 Verbose 和 Debug 级别 + 排除系统日志 + 去掉 WebView、OkHttp 等系统噪声标签 + 仅错误上下文 + 保留最后一条错误及其上方 10% 的日志 + 隐藏敏感内容 + 遮盖提示词和请求体正文 + 不保留时间 + 导出时去掉时间戳 语音识别 @@ -6010,6 +6022,7 @@ 没有日志可保存 + 没有错误日志可导出 === Operit 日志 === 日期: %1$s 总条数: %1$d @@ -6018,7 +6031,7 @@ 日志已保存至:%1$s 保存失败:无法创建文件,可能是存储权限问题 无法打开输出流 - MediaStore保存失败:%1$s + MediaStore保存失败:%1$s 无法创建下载目录 无法创建operit目录 文件创建失败或为空 diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilterTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilterTest.kt new file mode 100644 index 0000000000..591de4251c --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogExportFilterTest.kt @@ -0,0 +1,107 @@ +package com.ai.assistance.operit.ui.features.toolbox.screens.logcat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogExportFilterTest { + private val sample = """ + 2026-09-07 16:51:00.001 D/AIService: send start + 2026-09-07 16:51:00.002 I/ToolPkg: package loaded + 2026-09-07 16:51:00.003 D/WebView: cookie dump + 2026-09-07 16:51:00.004 W/ChatViewModel: slow path + 2026-09-07 16:51:00.005 E/GeminiProvider: request failed + java.lang.IllegalStateException: boom + at GeminiProvider.send(GeminiProvider.kt:1) + 2026-09-07 16:51:00.006 I/StreamFramework: done + """.trimIndent() + + @Test + fun identityKeepsEverything() { + val result = LogExportFilter.filterText(sample, LogExportOptions()) + assertEquals(6, result.exportedRecordCount) + assertTrue(result.lines.any { it.contains("D/AIService") }) + assertTrue(result.lines.any { it.contains("java.lang.IllegalStateException") }) + } + + @Test + fun excludeDebugDropsVerboseAndDebug() { + val result = LogExportFilter.filterText( + sample, + LogExportOptions(excludeDebug = true) + ) + assertFalse(result.lines.any { it.contains("D/AIService") }) + assertFalse(result.lines.any { it.contains("D/WebView") }) + assertTrue(result.lines.any { it.contains("I/ToolPkg") }) + assertTrue(result.lines.any { it.contains("E/GeminiProvider") }) + } + + @Test + fun excludeSystemDropsSystemTagsOnly() { + val result = LogExportFilter.filterText( + sample, + LogExportOptions(excludeSystem = true) + ) + assertFalse(result.lines.any { it.contains("D/WebView") }) + assertTrue(result.lines.any { it.contains("D/AIService") }) + } + + @Test + fun errorContextKeepsLastErrorAndTenPercentPrefix() { + val lines = (1..10).map { index -> + val level = if (index == 10) "E" else "I" + "2026-09-07 16:51:00.0${index.toString().padStart(2, '0')} $level/Tag$index: line $index" + } + val result = LogExportFilter.filterText( + lines.joinToString(separator = "\n"), + LogExportOptions(errorContextOnly = true) + ) + assertEquals(2, result.exportedRecordCount) + assertTrue(result.lines[0].contains("Tag9")) + assertTrue(result.lines[1].contains("Tag10")) + } + + @Test + fun errorContextWithoutErrorsExportsNothing() { + val noErrorLog = listOf( + "2026-09-07 16:51:00.001 I/ToolPkg: ok", + "2026-09-07 16:51:00.002 D/AIService: dbg" + ).joinToString(separator = "\n") + val result = LogExportFilter.filterText( + noErrorLog, + LogExportOptions(errorContextOnly = true) + ) + assertEquals(0, result.exportedRecordCount) + assertTrue(result.lines.isEmpty()) + } + + @Test + fun hideSensitiveRedactsPromptAndRequestBody() { + val raw = """ + 2026-09-07 16:51:00.001 D/GeminiProvider: 发现系统消息: # Role Configuration secret prompt + 2026-09-07 16:51:00.002 D/GeminiProvider: 请求体JSON: Part 1/2: {"systemInstruction":"hidden"} + extra continuation that still belongs to the dump + 2026-09-07 16:51:00.003 I/ToolPkg: package loaded + """.trimIndent() + val result = LogExportFilter.filterText( + raw, + LogExportOptions(hideSensitive = true) + ) + val joined = result.lines.joinToString(separator = "\n") + assertFalse(joined.contains("secret prompt")) + assertFalse(joined.contains("hidden")) + assertTrue(joined.contains("[redacted,")) + assertTrue(joined.contains("package loaded")) + } + + @Test + fun stripTimestampRemovesClockPrefix() { + val result = LogExportFilter.filterText( + "2026-09-07 16:51:00.001 I/ToolPkg: package loaded", + LogExportOptions(stripTimestamp = true) + ) + assertEquals(listOf("I/ToolPkg: package loaded"), result.lines) + } +} +