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