From c244072c1130893ca8eac8a22d43bcd90911d7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 23 Mar 2026 12:54:50 +0800 Subject: [PATCH] feat: CrashReportPage --- app/src/main/kotlin/li/songe/gkd/App.kt | 21 +++- .../main/kotlin/li/songe/gkd/MainActivity.kt | 5 + .../main/kotlin/li/songe/gkd/MainViewModel.kt | 36 ++++++ .../kotlin/li/songe/gkd/data/CrashData.kt | 30 +++++ .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 52 +-------- .../main/kotlin/li/songe/gkd/ui/AboutVm.kt | 1 - .../kotlin/li/songe/gkd/ui/CrashReportPage.kt | 108 ++++++++++++++++++ .../kotlin/li/songe/gkd/ui/CrashReportVm.kt | 12 ++ .../li/songe/gkd/ui/component/ShareLogDlg.kt | 80 +++++++++++++ .../kotlin/li/songe/gkd/util/FolderExt.kt | 6 +- .../main/kotlin/li/songe/gkd/util/LogUtils.kt | 10 +- 11 files changed, 302 insertions(+), 59 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/data/CrashData.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index d2ecf81b..990fc9e6 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -15,6 +15,7 @@ import android.database.ContentObserver import android.net.Uri import android.os.PowerManager import android.provider.Settings +import android.util.Log import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.view.inputmethod.InputMethodManager @@ -22,9 +23,9 @@ import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import li.songe.gkd.a11y.initA11yFeat +import li.songe.gkd.data.CrashData import li.songe.gkd.data.selfAppInfo import li.songe.gkd.notif.initChannel import li.songe.gkd.service.clearHttpSubs @@ -34,9 +35,11 @@ import li.songe.gkd.store.initStore import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.LogUtils import li.songe.gkd.util.PKG_FLAGS +import li.songe.gkd.util.deviceInfoDesc import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState import li.songe.gkd.util.initToast +import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast import org.lsposed.hiddenapibypass.HiddenApiBypass import kotlin.system.exitProcess @@ -196,7 +199,21 @@ class App : Application() { Thread.setDefaultUncaughtExceptionHandler { t, e -> toast(e.message ?: e.toString()) LogUtils.d("UncaughtExceptionHandler", t, e) - appScope.launch(Dispatchers.IO) { + val mtime = System.currentTimeMillis() + appScope.launchTry(Dispatchers.IO) { + CrashData( + id = mtime, + mtime = mtime, + device = deviceInfoDesc, + androidVersionCode = android.os.Build.VERSION.SDK_INT, + androidVersionName = android.os.Build.VERSION.RELEASE, + versionCode = META.versionCode, + versionName = META.versionName, + name = e::class.java.name, + message = e.message, + thread = t.name, + stackTrace = Log.getStackTraceString(e), + ).save() delay(1500) if (isActivityVisible) { startLaunchActivity() diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index f807ca5e..749ae441 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -94,6 +94,8 @@ import li.songe.gkd.ui.AuthA11yPage import li.songe.gkd.ui.AuthA11yRoute import li.songe.gkd.ui.BlockA11yAppListPage import li.songe.gkd.ui.BlockA11yAppListRoute +import li.songe.gkd.ui.CrashReportPage +import li.songe.gkd.ui.CrashReportRoute import li.songe.gkd.ui.EditBlockAppListPage import li.songe.gkd.ui.EditBlockAppListRoute import li.songe.gkd.ui.ImagePreviewPage @@ -119,6 +121,7 @@ import li.songe.gkd.ui.WebViewRoute import li.songe.gkd.ui.component.BuildDialog import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.ShareDataDialog +import li.songe.gkd.ui.component.ShareLogDlg import li.songe.gkd.ui.component.SubsSheet import li.songe.gkd.ui.component.TermsAcceptDialog import li.songe.gkd.ui.component.TextDialog @@ -278,6 +281,7 @@ class MainActivity : ComponentActivity() { entry { UpsertRuleGroupPage(it) } entry { SubsAppGroupListPage(it) } entry { AppConfigPage(it) } + entry { CrashReportPage() } }, transitionSpec = { slideInHorizontally(initialOffsetX = { it }) togetherWith @@ -308,6 +312,7 @@ class MainActivity : ComponentActivity() { mainVm.inputSubsLinkOption.ContentDialog() mainVm.ruleGroupState.Render() TextDialog(mainVm.textFlow) + ShareLogDlg(mainVm.showShareLogDlgFlow) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 81997f35..55795e07 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.a11y.useA11yServiceEnabledFlow import li.songe.gkd.a11y.useEnabledA11yServicesFlow +import li.songe.gkd.data.CrashData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData @@ -33,6 +34,7 @@ import li.songe.gkd.store.createTextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.AdvancedPageRoute import li.songe.gkd.ui.AppOpsAllowRoute +import li.songe.gkd.ui.CrashReportRoute import li.songe.gkd.ui.SnapshotPageRoute import li.songe.gkd.ui.WebViewRoute import li.songe.gkd.ui.component.AlertDialogOptions @@ -51,7 +53,10 @@ import li.songe.gkd.util.UpdateStatus import li.songe.gkd.util.appIconMapFlow import li.songe.gkd.util.clearCache import li.songe.gkd.util.client +import li.songe.gkd.util.crashFolder +import li.songe.gkd.util.crashTempFolder import li.songe.gkd.util.findOption +import li.songe.gkd.util.json import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.openWeChatScaner @@ -63,7 +68,9 @@ import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex import li.songe.gkd.util.updateSubscription import rikka.shizuku.Shizuku +import java.nio.file.Files import kotlin.reflect.jvm.jvmName +import kotlin.time.Duration.Companion.days class MainViewModel : BaseViewModel(), OnSimpleLife { companion object { @@ -323,6 +330,10 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { uiAutomationFlow.value?.shutdown() } + val showShareLogDlgFlow = MutableStateFlow(false) + + var tempCrashDataList = emptyList() + init { // preload appIconMapFlow.value @@ -360,6 +371,31 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { // preload githubCookieFlow.value } + viewModelScope.launchTry(Dispatchers.IO) { + val list = (crashTempFolder.listFiles() ?: emptyArray()).mapNotNull { + try { + json.decodeFromString(it.readText()) + } catch (e: Exception) { + LogUtils.d("解析崩溃日志失败: ${it.name}", e) + null + } + }.sortedBy { -it.mtime } + crashTempFolder.deleteRecursively() + val t = System.currentTimeMillis() + crashFolder.listFiles()?.filter { + val name = it.name + !list.any { f -> name == f.filename } + }?.forEach { + val mtime = Files.getLastModifiedTime(it.toPath()).toMillis() + if (t - mtime > 30.days.inWholeMilliseconds) { + it.delete() + } + } + tempCrashDataList = list + if (list.isNotEmpty()) { + navigatePage(CrashReportRoute) + } + } // for OnSimpleLife onCreated() diff --git a/app/src/main/kotlin/li/songe/gkd/data/CrashData.kt b/app/src/main/kotlin/li/songe/gkd/data/CrashData.kt new file mode 100644 index 00000000..bbbbdafb --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/data/CrashData.kt @@ -0,0 +1,30 @@ +package li.songe.gkd.data + +import kotlinx.serialization.Serializable +import li.songe.gkd.util.crashFolder +import li.songe.gkd.util.crashTempFolder +import li.songe.gkd.util.format +import li.songe.gkd.util.json + +@Serializable +data class CrashData( + val id: Long, + val mtime: Long, + val device: String, + val androidVersionCode: Int, + val androidVersionName: String, + val versionCode: Int, + val versionName: String, + val name: String, + val message: String?, + val thread: String, + val stackTrace: String, +) { + val filename get() = "gkd_crash-" + mtime.format("yyyyMMdd_HHmmss") + ".json" + fun save() { + val text = json.encodeToString(this) + crashFolder.resolve(filename).writeText(text) + crashTempFolder.resolve(filename).writeText(text) + } + +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 463c8180..c0196c37 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -79,7 +79,6 @@ import li.songe.gkd.util.PLAY_STORE_URL import li.songe.gkd.util.REPOSITORY_URL import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.UpdateChannelOption -import li.songe.gkd.util.buildLogFile import li.songe.gkd.util.findOption import li.songe.gkd.util.format import li.songe.gkd.util.getShareApkFile @@ -146,7 +145,6 @@ fun AboutPage() { }, ) } - var showShareLogDlg by vm.showShareLogDlgFlow.asMutableState() var showShareAppDlg by vm.showShareAppDlgFlow.asMutableState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( @@ -287,7 +285,7 @@ fun AboutPage() { title = "导出日志", imageVector = PerfIcon.Share, onClick = { - showShareLogDlg = true + mainVm.showShareLogDlgFlow.value = true } ) if (mainVm.updateStatus != null) { @@ -337,54 +335,6 @@ fun AboutPage() { } } - if (showShareLogDlg) { - Dialog(onDismissRequest = { showShareLogDlg = false }) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - ) { - val modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - Text( - text = "分享到其他应用", modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val logZipFile = buildLogFile() - context.shareFile(logZipFile, "分享日志文件") - } - }) - .then(modifier) - ) - Text( - text = "保存到下载", modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val logZipFile = buildLogFile() - context.saveFileToDownloads(logZipFile) - } - }) - .then(modifier) - ) - Text( - text = "生成链接(需科学上网)", - modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.uploadOptions.startTask( - getFile = { buildLogFile() } - ) - }) - .then(modifier) - ) - } - } - } - if (showShareAppDlg) { Dialog(onDismissRequest = { showShareAppDlg = false }) { Card( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt index 2a64c864..bdbd4dd9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt @@ -5,6 +5,5 @@ import kotlinx.coroutines.flow.MutableStateFlow class AboutVm : ViewModel() { val showInfoDlgFlow = MutableStateFlow(false) - val showShareLogDlgFlow = MutableStateFlow(false) val showShareAppDlgFlow = MutableStateFlow(false) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt new file mode 100644 index 00000000..9329a793 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt @@ -0,0 +1,108 @@ +package li.songe.gkd.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import li.songe.gkd.ui.component.CopyTextCard +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar +import li.songe.gkd.ui.component.useScrollBehaviorState +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable +import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.itemHorizontalPadding +import li.songe.gkd.ui.style.itemVerticalPadding +import li.songe.gkd.util.ISSUES_URL +import li.songe.gkd.util.throttle + + +@Serializable +data object CrashReportRoute : NavKey + +@Composable +fun CrashReportPage() { + val mainVm = LocalMainViewModel.current + val vm = viewModel() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = mainVm::popPage, + ) + }, + title = { + Text( + text = "崩溃记录", + modifier = Modifier.noRippleClickable(onClick = throttle { scrollKey.intValue++ }) + ) + }, + ) + }, + bottomBar = { + if (vm.crashDataList.isNotEmpty()) { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = throttle { mainVm.openUrl(ISSUES_URL) }, + ) { + Text(text = "问题反馈") + } + Spacer(modifier = Modifier.width(itemHorizontalPadding)) + TextButton( + onClick = { mainVm.showShareLogDlgFlow.value = true }, + ) { + Text(text = "导出日志") + } + Spacer(modifier = Modifier.width(itemHorizontalPadding)) + } + } + }, + ) { contentPadding -> + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(itemVerticalPadding) + ) { + if (vm.crashDataList.isNotEmpty()) { + vm.crashDataList.forEach { crashData -> + CopyTextCard( + text = crashData.stackTrace, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } else { + Spacer(modifier = Modifier.height(EmptyHeight)) + EmptyText() + } + Spacer(modifier = Modifier.height(EmptyHeight)) + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt new file mode 100644 index 00000000..d46728c3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt @@ -0,0 +1,12 @@ +package li.songe.gkd.ui + +import li.songe.gkd.MainViewModel +import li.songe.gkd.ui.share.BaseViewModel + +class CrashReportVm : BaseViewModel() { + val crashDataList = MainViewModel.instance.run { + val v = tempCrashDataList + tempCrashDataList = emptyList() + v + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt new file mode 100644 index 00000000..93e9bced --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt @@ -0,0 +1,80 @@ +package li.songe.gkd.ui.component + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.MainActivity +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState +import li.songe.gkd.util.buildLogFile +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.saveFileToDownloads +import li.songe.gkd.util.shareFile +import li.songe.gkd.util.throttle + +@Composable +fun ShareLogDlg(showShareLogDlgFlow: MutableStateFlow) { + var visible by showShareLogDlgFlow.asMutableState() + if (visible) { + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + Dialog(onDismissRequest = { visible = false }) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + ) { + val modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + Text( + text = "分享到其他应用", modifier = Modifier + .clickable(onClick = throttle { + visible = false + mainVm.viewModelScope.launchTry(Dispatchers.IO) { + val logZipFile = buildLogFile() + context.shareFile(logZipFile, "分享日志文件") + } + }) + .then(modifier) + ) + Text( + text = "保存到下载", modifier = Modifier + .clickable(onClick = throttle { + visible = false + mainVm.viewModelScope.launchTry(Dispatchers.IO) { + val logZipFile = buildLogFile() + context.saveFileToDownloads(logZipFile) + } + }) + .then(modifier) + ) + Text( + text = "生成链接(需科学上网)", + modifier = Modifier + .clickable(onClick = throttle { + visible = false + mainVm.uploadOptions.startTask( + getFile = { buildLogFile() } + ) + }) + .then(modifier) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index d3d5064d..a49035e7 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -36,6 +36,10 @@ val snapshotFolder: File get() = filesDir.resolve("snapshot").autoMk() val logFolder: File get() = filesDir.resolve("log").autoMk() +val crashFolder: File + get() = filesDir.resolve("crash").autoMk() +val crashTempFolder: File + get() = filesDir.resolve("crash/temp").autoMk() val privateStoreFolder: File get() = app.filesDir.resolve("store").autoMk() @@ -82,7 +86,7 @@ private data class AppJsonData( @WorkerThread fun buildLogFile(): File { val tempDir = createGkdTempDir() - val files = mutableListOf(dbFolder, storeFolder, subsFolder, logFolder) + val files = mutableListOf(dbFolder, storeFolder, subsFolder, logFolder, crashFolder) tempDir.resolve("meta.json").also { it.writeText(toJson5String(META)) files.add(it) diff --git a/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt index c74240cc..480dbf9a 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt @@ -46,17 +46,19 @@ object LogUtils { private val logFileExecutor = Executors.newSingleThreadExecutor() private const val MAX_LOG_KEEP_DAYS = 7 -private val deviceInfoText by lazy { - val deviceInfos = listOf( +val deviceInfoDesc by lazy { + listOf( android.os.Build.MANUFACTURER, android.os.Build.MODEL, DeviceBrand.getBrandName(), DeviceOs.getOsName() + DeviceOs.getOsVersionName() + DeviceOs.getOsBigVersionCode(), DeviceMarketName.getMarketName(app) - ) + ).joinToString("/") +} +private val deviceInfoText by lazy { buildString { append("Android: ${android.os.Build.VERSION.RELEASE} (${android.os.Build.VERSION.SDK_INT})\n") - append("Device: ${deviceInfos.joinToString("/")}\n") + append("Device: ${deviceInfoDesc}\n") append("App: ${META.versionName} (${META.versionCode})\n") } }