feat: CrashReportPage

This commit is contained in:
二刺螈
2026-03-23 12:54:50 +08:00
parent 16b0c235ef
commit c244072c11
11 changed files with 302 additions and 59 deletions

View File

@@ -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()

View File

@@ -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<UpsertRuleGroupRoute> { UpsertRuleGroupPage(it) }
entry<SubsAppGroupListRoute> { SubsAppGroupListPage(it) }
entry<AppConfigRoute> { AppConfigPage(it) }
entry<CrashReportRoute> { 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)
}
}
}

View File

@@ -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<CrashData>()
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<CrashData>(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()

View File

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

View File

@@ -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(

View File

@@ -5,6 +5,5 @@ import kotlinx.coroutines.flow.MutableStateFlow
class AboutVm : ViewModel() {
val showInfoDlgFlow = MutableStateFlow(false)
val showShareLogDlgFlow = MutableStateFlow(false)
val showShareAppDlgFlow = MutableStateFlow(false)
}

View File

@@ -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<CrashReportVm>()
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))
}
}
}

View File

@@ -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
}
}

View File

@@ -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<Boolean>) {
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)
)
}
}
}
}

View File

@@ -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)

View File

@@ -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")
}
}