Improve report export

This commit is contained in:
世界
2026-07-06 21:16:02 +08:00
parent 01d24b4439
commit 516f4bf295
10 changed files with 258 additions and 72 deletions
@@ -200,10 +200,10 @@ object CrashReportManager {
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
val zipFile = File(cacheDir, if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
@@ -212,7 +212,11 @@ object CrashReportManager {
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
if (!includeLog) {
File(strippedDir, GO_LOG_FILE_NAME).delete()
File(strippedDir, JVM_LOG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path, useAgeEncryption)
zipFile
}
@@ -29,6 +29,7 @@ data class OOMReportFile(
enum class Kind {
METADATA,
CONFIG,
GO_LOG,
PROFILE,
}
}
@@ -36,6 +37,7 @@ data class OOMReportFile(
object OOMReportManager {
private const val METADATA_FILE_NAME = "metadata.json"
private const val CONFIG_FILE_NAME = "configuration.json"
private const val GO_LOG_FILE_NAME = "go.log"
private const val CMDLINE_FILE_NAME = "cmdline"
private const val READ_MARKER_FILE_NAME = ".read"
private const val OOM_REPORTS_DIR_NAME = "oom_reports"
@@ -86,10 +88,15 @@ object OOMReportManager {
if (configFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile))
}
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
if (goLogFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.GO_LOG, "Log", goLogFile))
}
report.directory.listFiles()?.filter { file ->
file.isFile &&
file.name != METADATA_FILE_NAME &&
file.name != CONFIG_FILE_NAME &&
file.name != GO_LOG_FILE_NAME &&
file.name != CMDLINE_FILE_NAME &&
file.name != READ_MARKER_FILE_NAME
}?.sortedBy { it.name }?.forEach { file ->
@@ -133,10 +140,12 @@ object OOMReportManager {
fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
fun hasLogFile(report: OOMReport): Boolean = File(report.directory, GO_LOG_FILE_NAME).exists()
suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
val zipFile = File(cacheDir, if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
@@ -145,7 +154,10 @@ object OOMReportManager {
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
if (!includeLog) {
File(strippedDir, GO_LOG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path, useAgeEncryption)
zipFile
}
@@ -1,6 +1,8 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -23,8 +25,6 @@ import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@@ -70,10 +71,27 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) {
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<CrashReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
var showShareDialog by remember { mutableStateOf(false) }
var pendingZipFile by remember { mutableStateOf<File?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
val zipFile = pendingZipFile
pendingZipFile = null
if (uri != null && zipFile != null) {
scope.launch(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(uri)?.use { output ->
zipFile.inputStream().use { input -> input.copyTo(output) }
}
}
}
}
}
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
@@ -92,13 +110,13 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) {
val hasConfig = report != null && CrashReportManager.hasConfigFile(report)
fun shareReport(includeConfig: Boolean) {
fun shareReport(includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig)
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig, includeLog, useAgeEncryption)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
type = if (useAgeEncryption) "application/octet-stream" else "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
@@ -116,33 +134,8 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) {
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
IconButton(onClick = { showShareDialog = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
IconButton(onClick = {
scope.launch {
@@ -253,6 +246,25 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) {
}
}
}
if (showShareDialog && report != null) {
ReportShareDialog(
hasConfig = hasConfig,
hasLog = false,
onSave = { includeConfig, includeLog, useAgeEncryption ->
showShareDialog = false
scope.launch {
pendingZipFile = CrashReportManager.createZipArchive(report, includeConfig, includeLog, useAgeEncryption)
saveLauncher.launch(if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip")
}
},
onShare = { includeConfig, includeLog, useAgeEncryption ->
showShareDialog = false
shareReport(includeConfig, includeLog, useAgeEncryption)
},
onDismiss = { showShareDialog = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -1,6 +1,8 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -16,14 +18,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -60,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@@ -69,10 +71,27 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) {
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<OOMReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
var showShareDialog by remember { mutableStateOf(false) }
var pendingZipFile by remember { mutableStateOf<File?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip"),
) { uri ->
val zipFile = pendingZipFile
pendingZipFile = null
if (uri != null && zipFile != null) {
scope.launch(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(uri)?.use { output ->
zipFile.inputStream().use { input -> input.copyTo(output) }
}
}
}
}
}
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
@@ -90,14 +109,15 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) {
}
val hasConfig = report != null && OOMReportManager.hasConfigFile(report)
val hasLog = report != null && OOMReportManager.hasLogFile(report)
fun shareReport(includeConfig: Boolean) {
fun shareReport(includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig)
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig, includeLog, useAgeEncryption)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
type = if (useAgeEncryption) "application/octet-stream" else "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
@@ -115,33 +135,8 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) {
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
IconButton(onClick = { showShareDialog = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
IconButton(onClick = {
scope.launch {
@@ -219,6 +214,7 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) {
val icon = when (file.kind) {
OOMReportFile.Kind.METADATA -> Icons.Default.DataObject
OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings
OOMReportFile.Kind.GO_LOG -> Icons.Default.Description
OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal
}
ListItem(
@@ -257,6 +253,25 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) {
}
}
}
if (showShareDialog && report != null) {
ReportShareDialog(
hasConfig = hasConfig,
hasLog = hasLog,
onSave = { includeConfig, includeLog, useAgeEncryption ->
showShareDialog = false
scope.launch {
pendingZipFile = OOMReportManager.createZipArchive(report, includeConfig, includeLog, useAgeEncryption)
saveLauncher.launch(if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip")
}
},
onShare = { includeConfig, includeLog, useAgeEncryption ->
showShareDialog = false
shareReport(includeConfig, includeLog, useAgeEncryption)
},
onDismiss = { showShareDialog = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -0,0 +1,113 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.jeziellago.compose.markdowntext.MarkdownText
import io.nekohasekai.sfa.R
@Composable
fun ReportShareDialog(
hasConfig: Boolean,
hasLog: Boolean,
onSave: (includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) -> Unit,
onShare: (includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var includeConfig by remember { mutableStateOf(false) }
var includeLog by remember { mutableStateOf(true) }
var useAgeEncryption by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.report_share)) },
text = {
Column {
if (hasLog) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.report_with_log),
modifier = Modifier.weight(1f),
)
Switch(checked = includeLog, onCheckedChange = { includeLog = it })
}
}
if (hasConfig) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.report_with_config),
modifier = Modifier.weight(1f),
)
Switch(checked = includeConfig, onCheckedChange = { includeConfig = it })
}
}
if (hasConfig || hasLog) {
Text(
text = if (hasLog) {
stringResource(R.string.report_share_privacy_warning)
} else {
stringResource(R.string.report_share_privacy_warning_config)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.report_with_age_encryption),
modifier = Modifier.weight(1f),
)
Switch(checked = useAgeEncryption, onCheckedChange = { useAgeEncryption = it })
}
MarkdownText(
markdown = stringResource(R.string.report_age_encryption_description),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { onSave(includeConfig, includeLog, useAgeEncryption) }) {
Text(stringResource(R.string.save))
}
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
TextButton(onClick = { onShare(includeConfig, includeLog, useAgeEncryption) }) {
Text(stringResource(R.string.report_share))
}
}
},
)
}
+6
View File
@@ -537,6 +537,12 @@
<string name="report_delete">حذف</string>
<string name="report_share">اشتراک‌گذاری</string>
<string name="report_share_with_config">اشتراک‌گذاری با پیکربندی</string>
<string name="report_with_config">همراه با پیکربندی</string>
<string name="report_with_log">همراه با گزارش</string>
<string name="report_share_privacy_warning">فایل‌های گزارش و پیکربندی ممکن است حاوی محتوای خصوصی باشند و نباید عمومی شوند.</string>
<string name="report_share_privacy_warning_config">فایل‌های پیکربندی ممکن است حاوی محتوای خصوصی باشند و نباید عمومی شوند.</string>
<string name="report_with_age_encryption">رمزگذاری با age برای Project S</string>
<string name="report_age_encryption_description">[age](https://github.com/filosottile/age) یک ابزار رمزگذاری نامتقارن مدرن و امن است. با فعال‌سازی، فایل zip با کلید عمومی این پروژه رمزگذاری می‌شود تا بتوان آن را به‌صورت عمومی، مثلاً در GitHub issues، منتشر کرد.</string>
<string name="report_metadata">فراداده</string>
<string name="report_configuration">پیکربندی</string>
<string name="report_origin_local">محلی</string>
@@ -543,6 +543,12 @@
<string name="report_delete">Удалить</string>
<string name="report_share">Поделиться</string>
<string name="report_share_with_config">Поделиться с конфигурацией</string>
<string name="report_with_config">С конфигурацией</string>
<string name="report_with_log">С журналом</string>
<string name="report_share_privacy_warning">Журналы и файлы конфигурации могут содержать личные данные и не должны публиковаться.</string>
<string name="report_share_privacy_warning_config">Файлы конфигурации могут содержать личные данные и не должны публиковаться.</string>
<string name="report_with_age_encryption">Зашифровать с помощью age для Project S</string>
<string name="report_age_encryption_description">[age](https://github.com/filosottile/age) — современный и безопасный инструмент асимметричного шифрования. При включении ZIP-файл шифруется открытым ключом этого проекта, поэтому его можно публиковать в открытом доступе, например в GitHub issues.</string>
<string name="report_metadata">Метаданные</string>
<string name="report_configuration">Конфигурация</string>
<string name="report_origin_local">Локальный</string>
@@ -534,6 +534,12 @@
<string name="report_delete">删除</string>
<string name="report_share">分享</string>
<string name="report_share_with_config">附带配置分享</string>
<string name="report_with_config">附带配置</string>
<string name="report_with_log">附带日志</string>
<string name="report_share_privacy_warning">日志和配置文件可能包含隐私内容,不应该被公开发布。</string>
<string name="report_share_privacy_warning_config">配置文件可能包含隐私内容,不应该被公开发布。</string>
<string name="report_with_age_encryption">使用 age 加密给 Project S</string>
<string name="report_age_encryption_description">[age](https://github.com/filosottile/age) 是一个现代且安全的非对称加密工具。启用后,zip 文件将通过本项目的公钥加密,以便在 GitHub issues 等公共场合发布。</string>
<string name="report_metadata">元数据</string>
<string name="report_configuration">配置</string>
<string name="report_origin_local">本地</string>
@@ -537,6 +537,12 @@
<string name="report_delete">刪除</string>
<string name="report_share">分享</string>
<string name="report_share_with_config">附帶配置分享</string>
<string name="report_with_config">附帶配置</string>
<string name="report_with_log">附帶日誌</string>
<string name="report_share_privacy_warning">日誌和配置檔案可能包含隱私內容,不應該被公開發佈。</string>
<string name="report_share_privacy_warning_config">配置檔案可能包含隱私內容,不應該被公開發佈。</string>
<string name="report_with_age_encryption">使用 age 加密給 Project S</string>
<string name="report_age_encryption_description">[age](https://github.com/filosottile/age) 是一個現代且安全的非對稱加密工具。啟用後,zip 檔案將透過本專案的公鑰加密,以便在 GitHub issues 等公開場合發佈。</string>
<string name="report_metadata">元數據</string>
<string name="report_configuration">配置</string>
<string name="report_origin_local">本地</string>
+6
View File
@@ -543,6 +543,12 @@
<string name="report_delete">Delete</string>
<string name="report_share">Share</string>
<string name="report_share_with_config">Share With Configuration</string>
<string name="report_with_config">With Configuration</string>
<string name="report_with_log">With Log</string>
<string name="report_share_privacy_warning">Logs and configuration files may contain private content and should not be made public.</string>
<string name="report_share_privacy_warning_config">Configuration files may contain private content and should not be made public.</string>
<string name="report_with_age_encryption">Encrypt with age for Project S</string>
<string name="report_age_encryption_description">[age](https://github.com/filosottile/age) is a modern and secure asymmetric encryption tool. When enabled, the zip file is encrypted with this project\'s public key so it can be posted publicly, e.g. in GitHub issues.</string>
<string name="report_metadata">Metadata</string>
<string name="report_configuration">Configuration</string>
<string name="report_origin_local">Local</string>