Skip to content

Commit e573ed7

Browse files
committed
Tools View & Crash Report & OOM Report
1 parent cde4f96 commit e573ed7

27 files changed

Lines changed: 2699 additions & 48 deletions

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import go.Seq
1414
import io.nekohasekai.libbox.Libbox
1515
import io.nekohasekai.libbox.SetupOptions
1616
import io.nekohasekai.sfa.bg.AppChangeReceiver
17+
import io.nekohasekai.sfa.bg.CrashReportManager
18+
import io.nekohasekai.sfa.bg.OOMReportManager
1719
import io.nekohasekai.sfa.bg.UpdateProfileWork
1820
import io.nekohasekai.sfa.constant.Bugs
21+
import io.nekohasekai.sfa.database.Settings
1922
import io.nekohasekai.sfa.utils.AppLifecycleObserver
2023
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
2124
import io.nekohasekai.sfa.utils.HookStatusClient
@@ -69,6 +72,19 @@ class Application : Application() {
6972
workingDir.mkdirs()
7073
val tempDir = cacheDir
7174
tempDir.mkdirs()
75+
CrashReportManager.install(tempDir, workingDir)
76+
OOMReportManager.install(workingDir)
77+
setupLibbox(baseDir, workingDir, tempDir)
78+
}
79+
80+
fun reloadSetupOptions() {
81+
val baseDir = filesDir
82+
val workingDir = getExternalFilesDir(null) ?: return
83+
val tempDir = cacheDir
84+
setupLibbox(baseDir, workingDir, tempDir)
85+
}
86+
87+
private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) {
7288
Libbox.setup(
7389
SetupOptions().also {
7490
it.basePath = baseDir.path
@@ -77,9 +93,12 @@ class Application : Application() {
7793
it.fixAndroidStack = Bugs.fixAndroidStack
7894
it.logMaxLines = 3000
7995
it.debug = BuildConfig.DEBUG
96+
it.crashReportSource = "Application"
97+
it.oomKillerEnabled = Settings.oomKillerEnabled
98+
it.oomKillerDisabled = Settings.oomKillerDisabled
99+
it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L
80100
},
81101
)
82-
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
83102
}
84103

85104
companion object {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl
9494
}
9595

9696
private fun startCommandServer() {
97+
Application.application.reloadSetupOptions()
9798
val commandServer = CommandServer(this, platformInterface)
9899
commandServer.start()
99100
this.commandServer = commandServer
@@ -417,6 +418,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl
417418
}
418419
}
419420

421+
override fun triggerNativeCrash() {
422+
Thread {
423+
Thread.sleep(200)
424+
throw RuntimeException("debug native crash")
425+
}.start()
426+
}
427+
420428
override fun writeDebugMessage(message: String?) {
421429
Log.d("sing-box", message!!)
422430
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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 tempDir: File
55+
private lateinit var workingDir: 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(tempDir: File, workingDir: File) {
63+
this.tempDir = tempDir
64+
this.workingDir = workingDir
65+
val previous = Thread.getDefaultUncaughtExceptionHandler()
66+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
67+
writePendingJvmCrashReport(thread, throwable)
68+
previous?.uncaughtException(thread, throwable)
69+
}
70+
}
71+
72+
private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) {
73+
try {
74+
val writer = StringWriter()
75+
throwable.printStackTrace(PrintWriter(writer))
76+
File(tempDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString())
77+
val metadata = JSONObject().apply {
78+
put("source", "Application")
79+
put("crashedAt", formatTimestampISO8601(Date()))
80+
put("exceptionName", throwable.javaClass.name)
81+
put("exceptionReason", throwable.message ?: "")
82+
put("processName", Application.application.packageName)
83+
put("appVersion", BuildConfig.VERSION_CODE.toString())
84+
put("appMarketingVersion", BuildConfig.VERSION_NAME)
85+
runCatching {
86+
put("coreVersion", Libbox.version())
87+
put("goVersion", Libbox.goVersion())
88+
}
89+
}
90+
File(tempDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString())
91+
} catch (_: Throwable) {
92+
}
93+
}
94+
95+
suspend fun refresh() = withContext(Dispatchers.IO) {
96+
archivePendingJvmCrashReport()
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(tempDir, PENDING_JVM_CRASH_FILE_NAME)
104+
val metadataFile = File(tempDir, PENDING_JVM_METADATA_FILE_NAME)
105+
if (!crashFile.exists()) return
106+
val content = crashFile.readText().trim()
107+
if (content.isEmpty()) {
108+
crashFile.delete()
109+
metadataFile.delete()
110+
return
111+
}
112+
val crashDate = Date(crashFile.lastModified())
113+
val reportDir = nextAvailableReportDir(crashDate)
114+
reportDir.mkdirs()
115+
crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true)
116+
crashFile.delete()
117+
if (metadataFile.exists()) {
118+
metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true)
119+
metadataFile.delete()
120+
}
121+
}
122+
123+
private fun scanCrashReports(): List<CrashReport> {
124+
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
125+
if (!crashReportsDir.isDirectory) return emptyList()
126+
val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
127+
return directories.mapNotNull { dir ->
128+
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
129+
CrashReport(
130+
id = dir.name,
131+
date = date,
132+
directory = dir,
133+
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
134+
)
135+
}.sortedByDescending { it.date }
136+
}
137+
138+
fun availableFiles(report: CrashReport): List<CrashReportFile> {
139+
val files = mutableListOf<CrashReportFile>()
140+
val metadataFile = File(report.directory, METADATA_FILE_NAME)
141+
if (metadataFile.exists()) {
142+
files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile))
143+
}
144+
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
145+
if (goLogFile.exists()) {
146+
files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile))
147+
}
148+
val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME)
149+
if (jvmLogFile.exists()) {
150+
files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile))
151+
}
152+
val configFile = File(report.directory, CONFIG_FILE_NAME)
153+
if (configFile.exists()) {
154+
files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile))
155+
}
156+
return files
157+
}
158+
159+
fun loadFileContent(file: CrashReportFile): String {
160+
if (!file.file.exists()) return ""
161+
val content = file.file.readText()
162+
if (file.kind == CrashReportFile.Kind.METADATA) {
163+
return runCatching {
164+
JSONObject(content).toString(2)
165+
}.getOrDefault(content)
166+
}
167+
return content
168+
}
169+
170+
fun markAsRead(report: CrashReport) {
171+
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
172+
val updated = _reports.value.map {
173+
if (it.id == report.id) it.copy(isRead = true) else it
174+
}
175+
_reports.value = updated
176+
_unreadCount.value = updated.count { !it.isRead }
177+
}
178+
179+
suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) {
180+
report.directory.deleteRecursively()
181+
val updated = _reports.value.filter { it.id != report.id }
182+
_reports.value = updated
183+
_unreadCount.value = updated.count { !it.isRead }
184+
}
185+
186+
suspend fun deleteAll() = withContext(Dispatchers.IO) {
187+
File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively()
188+
_reports.value = emptyList()
189+
_unreadCount.value = 0
190+
}
191+
192+
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
193+
194+
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
195+
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
196+
cacheDir.mkdirs()
197+
val zipFile = File(cacheDir, "${report.id}.zip")
198+
zipFile.delete()
199+
val strippedDir = File(cacheDir, report.id)
200+
strippedDir.deleteRecursively()
201+
report.directory.copyRecursively(strippedDir, overwrite = true)
202+
File(strippedDir, READ_MARKER_FILE_NAME).delete()
203+
if (!includeConfig) {
204+
File(strippedDir, CONFIG_FILE_NAME).delete()
205+
}
206+
Libbox.createZipArchive(strippedDir.path, zipFile.path)
207+
zipFile
208+
}
209+
210+
private fun nextAvailableReportDir(date: Date): File {
211+
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
212+
val baseName = timestampFormat.format(date)
213+
var index = 0
214+
while (true) {
215+
val suffix = if (index == 0) "" else "-$index"
216+
val dir = File(crashReportsDir, baseName + suffix)
217+
if (!dir.exists()) return dir
218+
index++
219+
}
220+
}
221+
222+
private fun parseTimestamp(name: String): Date? {
223+
val components = name.split("-")
224+
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
225+
components.dropLast(1).joinToString("-")
226+
} else {
227+
name
228+
}
229+
return try {
230+
timestampFormat.parse(baseName)
231+
} catch (_: ParseException) {
232+
null
233+
}
234+
}
235+
236+
private fun formatTimestampISO8601(date: Date): String {
237+
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply {
238+
timeZone = TimeZone.getTimeZone("UTC")
239+
}
240+
return format.format(date)
241+
}
242+
}

0 commit comments

Comments
 (0)