Skip to content

Commit 2c44d14

Browse files
committed
Tools View & Crash Report & OOM Report
1 parent 81a7640 commit 2c44d14

27 files changed

Lines changed: 2735 additions & 64 deletions

app/src/main/java/io/nekohasekai/sfa/Application.kt

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import android.net.ConnectivityManager
1010
import android.net.wifi.WifiManager
1111
import android.os.PowerManager
1212
import androidx.core.content.getSystemService
13-
import go.Seq
1413
import io.nekohasekai.libbox.Libbox
1514
import io.nekohasekai.libbox.SetupOptions
1615
import io.nekohasekai.sfa.bg.AppChangeReceiver
16+
import io.nekohasekai.sfa.bg.CrashReportManager
17+
import io.nekohasekai.sfa.bg.OOMReportManager
1718
import io.nekohasekai.sfa.bg.UpdateProfileWork
1819
import io.nekohasekai.sfa.constant.Bugs
20+
import io.nekohasekai.sfa.database.Settings
1921
import io.nekohasekai.sfa.utils.AppLifecycleObserver
2022
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
2123
import io.nekohasekai.sfa.utils.HookStatusClient
@@ -43,9 +45,20 @@ class Application : Application() {
4345
HookStatusClient.register(this)
4446
PrivilegeSettingsClient.register(this)
4547

48+
val baseDir = filesDir
49+
baseDir.mkdirs()
50+
val workingDir = getExternalFilesDir(null)
51+
val tempDir = cacheDir
52+
tempDir.mkdirs()
53+
if (workingDir != null) {
54+
workingDir.mkdirs()
55+
CrashReportManager.install(workingDir, baseDir)
56+
OOMReportManager.install(workingDir)
57+
}
58+
4659
@Suppress("OPT_IN_USAGE")
4760
GlobalScope.launch(Dispatchers.IO) {
48-
initialize()
61+
initialize(baseDir, workingDir, tempDir)
4962
UpdateProfileWork.reconfigureUpdater()
5063
HookModuleUpdateNotifier.sync(this@Application)
5164
}
@@ -62,24 +75,33 @@ class Application : Application() {
6275
}
6376
}
6477

65-
private fun initialize() {
78+
private fun initialize(baseDir: File, workingDir: File?, tempDir: File) {
79+
val actualWorkingDir = workingDir ?: return
80+
setupLibbox(baseDir, actualWorkingDir, tempDir)
81+
}
82+
83+
fun reloadSetupOptions() {
6684
val baseDir = filesDir
67-
baseDir.mkdirs()
6885
val workingDir = getExternalFilesDir(null) ?: return
69-
workingDir.mkdirs()
7086
val tempDir = cacheDir
71-
tempDir.mkdirs()
72-
Libbox.setup(
73-
SetupOptions().also {
74-
it.basePath = baseDir.path
75-
it.workingPath = workingDir.path
76-
it.tempPath = tempDir.path
77-
it.fixAndroidStack = Bugs.fixAndroidStack
78-
it.logMaxLines = 3000
79-
it.debug = BuildConfig.DEBUG
80-
},
81-
)
82-
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
87+
Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir))
88+
}
89+
90+
private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) {
91+
Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir))
92+
}
93+
94+
private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also {
95+
it.basePath = baseDir.path
96+
it.workingPath = workingDir.path
97+
it.tempPath = tempDir.path
98+
it.fixAndroidStack = Bugs.fixAndroidStack
99+
it.logMaxLines = 3000
100+
it.debug = BuildConfig.DEBUG
101+
it.crashReportSource = "Application"
102+
it.oomKillerEnabled = Settings.oomKillerEnabled
103+
it.oomKillerDisabled = Settings.oomKillerDisabled
104+
it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L
83105
}
84106

85107
companion object {

app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl
417417
}
418418
}
419419

420+
override fun triggerNativeCrash() {
421+
Thread {
422+
Thread.sleep(200)
423+
throw RuntimeException("debug native crash")
424+
}.start()
425+
}
426+
420427
override fun writeDebugMessage(message: String?) {
421428
Log.d("sing-box", message!!)
422429
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package io.nekohasekai.sfa.bg
2+
3+
import io.nekohasekai.libbox.Libbox
4+
import io.nekohasekai.sfa.Application
5+
import io.nekohasekai.sfa.BuildConfig
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.StateFlow
9+
import kotlinx.coroutines.withContext
10+
import org.json.JSONObject
11+
import java.io.File
12+
import java.io.PrintWriter
13+
import java.io.StringWriter
14+
import java.text.ParseException
15+
import java.text.SimpleDateFormat
16+
import java.util.Date
17+
import java.util.Locale
18+
import java.util.TimeZone
19+
20+
data class CrashReport(
21+
val id: String,
22+
val date: Date,
23+
val directory: File,
24+
val isRead: Boolean,
25+
)
26+
27+
data class CrashReportFile(
28+
val kind: Kind,
29+
val displayName: String,
30+
val file: File,
31+
) {
32+
enum class Kind {
33+
METADATA,
34+
GO_LOG,
35+
JVM_LOG,
36+
CONFIG,
37+
}
38+
}
39+
40+
object CrashReportManager {
41+
private const val METADATA_FILE_NAME = "metadata.json"
42+
private const val GO_LOG_FILE_NAME = "go.log"
43+
private const val JVM_LOG_FILE_NAME = "jvm.log"
44+
private const val CONFIG_FILE_NAME = "configuration.json"
45+
private const val READ_MARKER_FILE_NAME = ".read"
46+
private const val CRASH_REPORTS_DIR_NAME = "crash_reports"
47+
private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log"
48+
private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json"
49+
50+
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
51+
timeZone = TimeZone.getTimeZone("UTC")
52+
}
53+
54+
private lateinit var workingDir: File
55+
private lateinit var baseDir: File
56+
57+
private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
58+
val reports: StateFlow<List<CrashReport>> = _reports
59+
private val _unreadCount = MutableStateFlow(0)
60+
val unreadCount: StateFlow<Int> = _unreadCount
61+
62+
fun install(workingDir: File, baseDir: File) {
63+
this.workingDir = workingDir
64+
this.baseDir = baseDir
65+
archivePendingJvmCrashReport()
66+
val previous = Thread.getDefaultUncaughtExceptionHandler()
67+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
68+
writePendingJvmCrashReport(thread, throwable)
69+
previous?.uncaughtException(thread, throwable)
70+
}
71+
}
72+
73+
private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) {
74+
try {
75+
val writer = StringWriter()
76+
throwable.printStackTrace(PrintWriter(writer))
77+
File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString())
78+
val metadata = JSONObject().apply {
79+
put("source", "Application")
80+
put("crashedAt", formatTimestampISO8601(Date()))
81+
put("exceptionName", throwable.javaClass.name)
82+
put("exceptionReason", throwable.message ?: "")
83+
put("processName", Application.application.packageName)
84+
put("appVersion", BuildConfig.VERSION_CODE.toString())
85+
put("appMarketingVersion", BuildConfig.VERSION_NAME)
86+
runCatching {
87+
put("coreVersion", Libbox.version())
88+
put("goVersion", Libbox.goVersion())
89+
}
90+
}
91+
File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString())
92+
} catch (_: Throwable) {
93+
}
94+
}
95+
96+
suspend fun refresh() = withContext(Dispatchers.IO) {
97+
val reports = scanCrashReports()
98+
_reports.value = reports
99+
_unreadCount.value = reports.count { !it.isRead }
100+
}
101+
102+
private fun archivePendingJvmCrashReport() {
103+
val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME)
104+
val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME)
105+
val configFile = File(baseDir, CONFIG_FILE_NAME)
106+
if (!crashFile.exists()) return
107+
val content = crashFile.readText().trim()
108+
if (content.isEmpty()) {
109+
crashFile.delete()
110+
metadataFile.delete()
111+
configFile.delete()
112+
return
113+
}
114+
val crashDate = Date(crashFile.lastModified())
115+
val reportDir = nextAvailableReportDir(crashDate)
116+
reportDir.mkdirs()
117+
crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true)
118+
crashFile.delete()
119+
if (metadataFile.exists()) {
120+
metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true)
121+
metadataFile.delete()
122+
}
123+
if (configFile.exists()) {
124+
val configContent = runCatching { configFile.readText() }.getOrNull()?.trim()
125+
if (!configContent.isNullOrEmpty()) {
126+
configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true)
127+
}
128+
configFile.delete()
129+
}
130+
}
131+
132+
private fun scanCrashReports(): List<CrashReport> {
133+
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
134+
if (!crashReportsDir.isDirectory) return emptyList()
135+
val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
136+
return directories.mapNotNull { dir ->
137+
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
138+
CrashReport(
139+
id = dir.name,
140+
date = date,
141+
directory = dir,
142+
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
143+
)
144+
}.sortedByDescending { it.date }
145+
}
146+
147+
fun availableFiles(report: CrashReport): List<CrashReportFile> {
148+
val files = mutableListOf<CrashReportFile>()
149+
val metadataFile = File(report.directory, METADATA_FILE_NAME)
150+
if (metadataFile.exists()) {
151+
files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile))
152+
}
153+
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
154+
if (goLogFile.exists()) {
155+
files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile))
156+
}
157+
val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME)
158+
if (jvmLogFile.exists()) {
159+
files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile))
160+
}
161+
val configFile = File(report.directory, CONFIG_FILE_NAME)
162+
if (configFile.exists()) {
163+
files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile))
164+
}
165+
return files
166+
}
167+
168+
fun loadFileContent(file: CrashReportFile): String {
169+
if (!file.file.exists()) return ""
170+
val content = file.file.readText()
171+
if (file.kind == CrashReportFile.Kind.METADATA) {
172+
return runCatching {
173+
JSONObject(content).toString(2)
174+
}.getOrDefault(content)
175+
}
176+
return content
177+
}
178+
179+
fun markAsRead(report: CrashReport) {
180+
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
181+
val updated = _reports.value.map {
182+
if (it.id == report.id) it.copy(isRead = true) else it
183+
}
184+
_reports.value = updated
185+
_unreadCount.value = updated.count { !it.isRead }
186+
}
187+
188+
suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) {
189+
report.directory.deleteRecursively()
190+
val updated = _reports.value.filter { it.id != report.id }
191+
_reports.value = updated
192+
_unreadCount.value = updated.count { !it.isRead }
193+
}
194+
195+
suspend fun deleteAll() = withContext(Dispatchers.IO) {
196+
File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively()
197+
_reports.value = emptyList()
198+
_unreadCount.value = 0
199+
}
200+
201+
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
202+
203+
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
204+
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
205+
cacheDir.mkdirs()
206+
val zipFile = File(cacheDir, "${report.id}.zip")
207+
zipFile.delete()
208+
val strippedDir = File(cacheDir, report.id)
209+
strippedDir.deleteRecursively()
210+
report.directory.copyRecursively(strippedDir, overwrite = true)
211+
File(strippedDir, READ_MARKER_FILE_NAME).delete()
212+
if (!includeConfig) {
213+
File(strippedDir, CONFIG_FILE_NAME).delete()
214+
}
215+
Libbox.createZipArchive(strippedDir.path, zipFile.path)
216+
zipFile
217+
}
218+
219+
private fun nextAvailableReportDir(date: Date): File {
220+
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
221+
val baseName = timestampFormat.format(date)
222+
var index = 0
223+
while (true) {
224+
val suffix = if (index == 0) "" else "-$index"
225+
val dir = File(crashReportsDir, baseName + suffix)
226+
if (!dir.exists()) return dir
227+
index++
228+
}
229+
}
230+
231+
private fun parseTimestamp(name: String): Date? {
232+
val components = name.split("-")
233+
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
234+
components.dropLast(1).joinToString("-")
235+
} else {
236+
name
237+
}
238+
return try {
239+
timestampFormat.parse(baseName)
240+
} catch (_: ParseException) {
241+
null
242+
}
243+
}
244+
245+
private fun formatTimestampISO8601(date: Date): String {
246+
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply {
247+
timeZone = TimeZone.getTimeZone("UTC")
248+
}
249+
return format.format(date)
250+
}
251+
}

0 commit comments

Comments
 (0)