Tools View & Crash Report & OOM Report
This commit is contained in:
@@ -10,12 +10,14 @@ import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import go.Seq
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.libbox.SetupOptions
|
||||
import io.nekohasekai.sfa.bg.AppChangeReceiver
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.bg.UpdateProfileWork
|
||||
import io.nekohasekai.sfa.constant.Bugs
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.utils.AppLifecycleObserver
|
||||
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
|
||||
import io.nekohasekai.sfa.utils.HookStatusClient
|
||||
@@ -43,9 +45,20 @@ class Application : Application() {
|
||||
HookStatusClient.register(this)
|
||||
PrivilegeSettingsClient.register(this)
|
||||
|
||||
val baseDir = filesDir
|
||||
baseDir.mkdirs()
|
||||
val workingDir = getExternalFilesDir(null)
|
||||
val tempDir = cacheDir
|
||||
tempDir.mkdirs()
|
||||
if (workingDir != null) {
|
||||
workingDir.mkdirs()
|
||||
CrashReportManager.install(workingDir, baseDir)
|
||||
OOMReportManager.install(workingDir)
|
||||
}
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
initialize()
|
||||
initialize(baseDir, workingDir, tempDir)
|
||||
UpdateProfileWork.reconfigureUpdater()
|
||||
HookModuleUpdateNotifier.sync(this@Application)
|
||||
}
|
||||
@@ -62,24 +75,33 @@ class Application : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
private fun initialize(baseDir: File, workingDir: File?, tempDir: File) {
|
||||
val actualWorkingDir = workingDir ?: return
|
||||
setupLibbox(baseDir, actualWorkingDir, tempDir)
|
||||
}
|
||||
|
||||
fun reloadSetupOptions() {
|
||||
val baseDir = filesDir
|
||||
baseDir.mkdirs()
|
||||
val workingDir = getExternalFilesDir(null) ?: return
|
||||
workingDir.mkdirs()
|
||||
val tempDir = cacheDir
|
||||
tempDir.mkdirs()
|
||||
Libbox.setup(
|
||||
SetupOptions().also {
|
||||
it.basePath = baseDir.path
|
||||
it.workingPath = workingDir.path
|
||||
it.tempPath = tempDir.path
|
||||
it.fixAndroidStack = Bugs.fixAndroidStack
|
||||
it.logMaxLines = 3000
|
||||
it.debug = BuildConfig.DEBUG
|
||||
},
|
||||
)
|
||||
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
|
||||
Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir))
|
||||
}
|
||||
|
||||
private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) {
|
||||
Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir))
|
||||
}
|
||||
|
||||
private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also {
|
||||
it.basePath = baseDir.path
|
||||
it.workingPath = workingDir.path
|
||||
it.tempPath = tempDir.path
|
||||
it.fixAndroidStack = Bugs.fixAndroidStack
|
||||
it.logMaxLines = 3000
|
||||
it.debug = BuildConfig.DEBUG
|
||||
it.crashReportSource = "Application"
|
||||
it.oomKillerEnabled = Settings.oomKillerEnabled
|
||||
it.oomKillerDisabled = Settings.oomKillerDisabled
|
||||
it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -417,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl
|
||||
}
|
||||
}
|
||||
|
||||
override fun triggerNativeCrash() {
|
||||
Thread {
|
||||
Thread.sleep(200)
|
||||
throw RuntimeException("debug native crash")
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun writeDebugMessage(message: String?) {
|
||||
Log.d("sing-box", message!!)
|
||||
}
|
||||
|
||||
251
app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt
Normal file
251
app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt
Normal file
@@ -0,0 +1,251 @@
|
||||
package io.nekohasekai.sfa.bg
|
||||
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
data class CrashReport(
|
||||
val id: String,
|
||||
val date: Date,
|
||||
val directory: File,
|
||||
val isRead: Boolean,
|
||||
)
|
||||
|
||||
data class CrashReportFile(
|
||||
val kind: Kind,
|
||||
val displayName: String,
|
||||
val file: File,
|
||||
) {
|
||||
enum class Kind {
|
||||
METADATA,
|
||||
GO_LOG,
|
||||
JVM_LOG,
|
||||
CONFIG,
|
||||
}
|
||||
}
|
||||
|
||||
object CrashReportManager {
|
||||
private const val METADATA_FILE_NAME = "metadata.json"
|
||||
private const val GO_LOG_FILE_NAME = "go.log"
|
||||
private const val JVM_LOG_FILE_NAME = "jvm.log"
|
||||
private const val CONFIG_FILE_NAME = "configuration.json"
|
||||
private const val READ_MARKER_FILE_NAME = ".read"
|
||||
private const val CRASH_REPORTS_DIR_NAME = "crash_reports"
|
||||
private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log"
|
||||
private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json"
|
||||
|
||||
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private lateinit var workingDir: File
|
||||
private lateinit var baseDir: File
|
||||
|
||||
private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
|
||||
val reports: StateFlow<List<CrashReport>> = _reports
|
||||
private val _unreadCount = MutableStateFlow(0)
|
||||
val unreadCount: StateFlow<Int> = _unreadCount
|
||||
|
||||
fun install(workingDir: File, baseDir: File) {
|
||||
this.workingDir = workingDir
|
||||
this.baseDir = baseDir
|
||||
archivePendingJvmCrashReport()
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
writePendingJvmCrashReport(thread, throwable)
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) {
|
||||
try {
|
||||
val writer = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(writer))
|
||||
File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString())
|
||||
val metadata = JSONObject().apply {
|
||||
put("source", "Application")
|
||||
put("crashedAt", formatTimestampISO8601(Date()))
|
||||
put("exceptionName", throwable.javaClass.name)
|
||||
put("exceptionReason", throwable.message ?: "")
|
||||
put("processName", Application.application.packageName)
|
||||
put("appVersion", BuildConfig.VERSION_CODE.toString())
|
||||
put("appMarketingVersion", BuildConfig.VERSION_NAME)
|
||||
runCatching {
|
||||
put("coreVersion", Libbox.version())
|
||||
put("goVersion", Libbox.goVersion())
|
||||
}
|
||||
}
|
||||
File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString())
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh() = withContext(Dispatchers.IO) {
|
||||
val reports = scanCrashReports()
|
||||
_reports.value = reports
|
||||
_unreadCount.value = reports.count { !it.isRead }
|
||||
}
|
||||
|
||||
private fun archivePendingJvmCrashReport() {
|
||||
val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME)
|
||||
val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME)
|
||||
val configFile = File(baseDir, CONFIG_FILE_NAME)
|
||||
if (!crashFile.exists()) return
|
||||
val content = crashFile.readText().trim()
|
||||
if (content.isEmpty()) {
|
||||
crashFile.delete()
|
||||
metadataFile.delete()
|
||||
configFile.delete()
|
||||
return
|
||||
}
|
||||
val crashDate = Date(crashFile.lastModified())
|
||||
val reportDir = nextAvailableReportDir(crashDate)
|
||||
reportDir.mkdirs()
|
||||
crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true)
|
||||
crashFile.delete()
|
||||
if (metadataFile.exists()) {
|
||||
metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true)
|
||||
metadataFile.delete()
|
||||
}
|
||||
if (configFile.exists()) {
|
||||
val configContent = runCatching { configFile.readText() }.getOrNull()?.trim()
|
||||
if (!configContent.isNullOrEmpty()) {
|
||||
configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true)
|
||||
}
|
||||
configFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanCrashReports(): List<CrashReport> {
|
||||
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
|
||||
if (!crashReportsDir.isDirectory) return emptyList()
|
||||
val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
|
||||
return directories.mapNotNull { dir ->
|
||||
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
|
||||
CrashReport(
|
||||
id = dir.name,
|
||||
date = date,
|
||||
directory = dir,
|
||||
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
|
||||
)
|
||||
}.sortedByDescending { it.date }
|
||||
}
|
||||
|
||||
fun availableFiles(report: CrashReport): List<CrashReportFile> {
|
||||
val files = mutableListOf<CrashReportFile>()
|
||||
val metadataFile = File(report.directory, METADATA_FILE_NAME)
|
||||
if (metadataFile.exists()) {
|
||||
files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile))
|
||||
}
|
||||
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
|
||||
if (goLogFile.exists()) {
|
||||
files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile))
|
||||
}
|
||||
val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME)
|
||||
if (jvmLogFile.exists()) {
|
||||
files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile))
|
||||
}
|
||||
val configFile = File(report.directory, CONFIG_FILE_NAME)
|
||||
if (configFile.exists()) {
|
||||
files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
fun loadFileContent(file: CrashReportFile): String {
|
||||
if (!file.file.exists()) return ""
|
||||
val content = file.file.readText()
|
||||
if (file.kind == CrashReportFile.Kind.METADATA) {
|
||||
return runCatching {
|
||||
JSONObject(content).toString(2)
|
||||
}.getOrDefault(content)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
fun markAsRead(report: CrashReport) {
|
||||
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
|
||||
val updated = _reports.value.map {
|
||||
if (it.id == report.id) it.copy(isRead = true) else it
|
||||
}
|
||||
_reports.value = updated
|
||||
_unreadCount.value = updated.count { !it.isRead }
|
||||
}
|
||||
|
||||
suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) {
|
||||
report.directory.deleteRecursively()
|
||||
val updated = _reports.value.filter { it.id != report.id }
|
||||
_reports.value = updated
|
||||
_unreadCount.value = updated.count { !it.isRead }
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(Dispatchers.IO) {
|
||||
File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively()
|
||||
_reports.value = emptyList()
|
||||
_unreadCount.value = 0
|
||||
}
|
||||
|
||||
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
|
||||
|
||||
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
|
||||
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
|
||||
cacheDir.mkdirs()
|
||||
val zipFile = File(cacheDir, "${report.id}.zip")
|
||||
zipFile.delete()
|
||||
val strippedDir = File(cacheDir, report.id)
|
||||
strippedDir.deleteRecursively()
|
||||
report.directory.copyRecursively(strippedDir, overwrite = true)
|
||||
File(strippedDir, READ_MARKER_FILE_NAME).delete()
|
||||
if (!includeConfig) {
|
||||
File(strippedDir, CONFIG_FILE_NAME).delete()
|
||||
}
|
||||
Libbox.createZipArchive(strippedDir.path, zipFile.path)
|
||||
zipFile
|
||||
}
|
||||
|
||||
private fun nextAvailableReportDir(date: Date): File {
|
||||
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
|
||||
val baseName = timestampFormat.format(date)
|
||||
var index = 0
|
||||
while (true) {
|
||||
val suffix = if (index == 0) "" else "-$index"
|
||||
val dir = File(crashReportsDir, baseName + suffix)
|
||||
if (!dir.exists()) return dir
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTimestamp(name: String): Date? {
|
||||
val components = name.split("-")
|
||||
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
|
||||
components.dropLast(1).joinToString("-")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
return try {
|
||||
timestampFormat.parse(baseName)
|
||||
} catch (_: ParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestampISO8601(date: Date): String {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
return format.format(date)
|
||||
}
|
||||
}
|
||||
165
app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt
Normal file
165
app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt
Normal file
@@ -0,0 +1,165 @@
|
||||
package io.nekohasekai.sfa.bg
|
||||
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
data class OOMReport(
|
||||
val id: String,
|
||||
val date: Date,
|
||||
val directory: File,
|
||||
val isRead: Boolean,
|
||||
)
|
||||
|
||||
data class OOMReportFile(
|
||||
val kind: Kind,
|
||||
val displayName: String,
|
||||
val file: File,
|
||||
) {
|
||||
enum class Kind {
|
||||
METADATA,
|
||||
CONFIG,
|
||||
PROFILE,
|
||||
}
|
||||
}
|
||||
|
||||
object OOMReportManager {
|
||||
private const val METADATA_FILE_NAME = "metadata.json"
|
||||
private const val CONFIG_FILE_NAME = "configuration.json"
|
||||
private const val CMDLINE_FILE_NAME = "cmdline"
|
||||
private const val READ_MARKER_FILE_NAME = ".read"
|
||||
private const val OOM_REPORTS_DIR_NAME = "oom_reports"
|
||||
|
||||
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private lateinit var workingDir: File
|
||||
|
||||
private val _reports = MutableStateFlow<List<OOMReport>>(emptyList())
|
||||
val reports: StateFlow<List<OOMReport>> = _reports
|
||||
private val _unreadCount = MutableStateFlow(0)
|
||||
val unreadCount: StateFlow<Int> = _unreadCount
|
||||
|
||||
fun install(workingDir: File) {
|
||||
this.workingDir = workingDir
|
||||
}
|
||||
|
||||
suspend fun refresh() = withContext(Dispatchers.IO) {
|
||||
val reports = scanReports()
|
||||
_reports.value = reports
|
||||
_unreadCount.value = reports.count { !it.isRead }
|
||||
}
|
||||
|
||||
private fun scanReports(): List<OOMReport> {
|
||||
val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME)
|
||||
if (!reportsDir.isDirectory) return emptyList()
|
||||
val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
|
||||
return directories.mapNotNull { dir ->
|
||||
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
|
||||
OOMReport(
|
||||
id = dir.name,
|
||||
date = date,
|
||||
directory = dir,
|
||||
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
|
||||
)
|
||||
}.sortedByDescending { it.date }
|
||||
}
|
||||
|
||||
fun availableFiles(report: OOMReport): List<OOMReportFile> {
|
||||
val files = mutableListOf<OOMReportFile>()
|
||||
val metadataFile = File(report.directory, METADATA_FILE_NAME)
|
||||
if (metadataFile.exists()) {
|
||||
files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile))
|
||||
}
|
||||
val configFile = File(report.directory, CONFIG_FILE_NAME)
|
||||
if (configFile.exists()) {
|
||||
files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile))
|
||||
}
|
||||
report.directory.listFiles()?.filter { file ->
|
||||
file.isFile &&
|
||||
file.name != METADATA_FILE_NAME &&
|
||||
file.name != CONFIG_FILE_NAME &&
|
||||
file.name != CMDLINE_FILE_NAME &&
|
||||
file.name != READ_MARKER_FILE_NAME
|
||||
}?.sortedBy { it.name }?.forEach { file ->
|
||||
files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
fun loadFileContent(file: OOMReportFile): String {
|
||||
if (!file.file.exists()) return ""
|
||||
val content = file.file.readText()
|
||||
if (file.kind == OOMReportFile.Kind.METADATA) {
|
||||
return runCatching {
|
||||
JSONObject(content).toString(2)
|
||||
}.getOrDefault(content)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
fun markAsRead(report: OOMReport) {
|
||||
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
|
||||
val updated = _reports.value.map {
|
||||
if (it.id == report.id) it.copy(isRead = true) else it
|
||||
}
|
||||
_reports.value = updated
|
||||
_unreadCount.value = updated.count { !it.isRead }
|
||||
}
|
||||
|
||||
suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) {
|
||||
report.directory.deleteRecursively()
|
||||
val updated = _reports.value.filter { it.id != report.id }
|
||||
_reports.value = updated
|
||||
_unreadCount.value = updated.count { !it.isRead }
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(Dispatchers.IO) {
|
||||
File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively()
|
||||
_reports.value = emptyList()
|
||||
_unreadCount.value = 0
|
||||
}
|
||||
|
||||
fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
|
||||
|
||||
suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
|
||||
val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME)
|
||||
cacheDir.mkdirs()
|
||||
val zipFile = File(cacheDir, "${report.id}.zip")
|
||||
zipFile.delete()
|
||||
val strippedDir = File(cacheDir, report.id)
|
||||
strippedDir.deleteRecursively()
|
||||
report.directory.copyRecursively(strippedDir, overwrite = true)
|
||||
File(strippedDir, READ_MARKER_FILE_NAME).delete()
|
||||
if (!includeConfig) {
|
||||
File(strippedDir, CONFIG_FILE_NAME).delete()
|
||||
}
|
||||
Libbox.createZipArchive(strippedDir.path, zipFile.path)
|
||||
zipFile
|
||||
}
|
||||
|
||||
private fun parseTimestamp(name: String): Date? {
|
||||
val components = name.split("-")
|
||||
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
|
||||
components.dropLast(1).joinToString("-")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
return try {
|
||||
timestampFormat.parse(baseName)
|
||||
} catch (_: ParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,9 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.BoxService
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||
import io.nekohasekai.sfa.compat.WindowSizeClassCompat
|
||||
@@ -126,6 +129,7 @@ import io.nekohasekai.sfa.update.UpdateState
|
||||
import io.nekohasekai.sfa.vendor.Vendor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -327,6 +331,89 @@ class MainActivity :
|
||||
|
||||
// Snackbar state
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Error dialog state for UiEvent.ShowError
|
||||
var showErrorDialog by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var pendingApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
|
||||
var activeApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
|
||||
var applyServiceChangeJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
fun mergeApplyServiceChangeMode(
|
||||
current: UiEvent.ApplyServiceChange.Mode?,
|
||||
incoming: UiEvent.ApplyServiceChange.Mode,
|
||||
): UiEvent.ApplyServiceChange.Mode = when {
|
||||
current == UiEvent.ApplyServiceChange.Mode.Restart ||
|
||||
incoming == UiEvent.ApplyServiceChange.Mode.Restart -> {
|
||||
UiEvent.ApplyServiceChange.Mode.Restart
|
||||
}
|
||||
|
||||
else -> incoming
|
||||
}
|
||||
|
||||
fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) {
|
||||
if (currentServiceStatus != Status.Started) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode)
|
||||
|
||||
val activeMode = activeApplyServiceChangeMode
|
||||
if (activeMode != null &&
|
||||
mergeApplyServiceChangeMode(activeMode, mode) != activeMode
|
||||
) {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
}
|
||||
|
||||
if (applyServiceChangeJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
applyServiceChangeJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val modeToShow = pendingApplyServiceChangeMode ?: break
|
||||
pendingApplyServiceChangeMode = null
|
||||
activeApplyServiceChangeMode = modeToShow
|
||||
val (message, actionLabel) =
|
||||
when (modeToShow) {
|
||||
UiEvent.ApplyServiceChange.Mode.Reload -> {
|
||||
getString(R.string.service_reload_required) to
|
||||
getString(R.string.action_reload)
|
||||
}
|
||||
|
||||
UiEvent.ApplyServiceChange.Mode.Restart -> {
|
||||
getString(R.string.service_restart_required) to
|
||||
getString(R.string.action_restart)
|
||||
}
|
||||
}
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = message,
|
||||
actionLabel = actionLabel,
|
||||
duration = androidx.compose.material3.SnackbarDuration.Short,
|
||||
)
|
||||
activeApplyServiceChangeMode = null
|
||||
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
|
||||
try {
|
||||
when (modeToShow) {
|
||||
UiEvent.ApplyServiceChange.Mode.Reload -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Libbox.newStandaloneCommandClient().serviceReload()
|
||||
}
|
||||
}
|
||||
|
||||
UiEvent.ApplyServiceChange.Mode.Restart -> {
|
||||
restartServiceForApplyChange()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message ?: e.toString()
|
||||
showErrorDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Groups Sheet state
|
||||
var showGroupsSheet by remember { mutableStateOf(false) }
|
||||
@@ -335,8 +422,6 @@ class MainActivity :
|
||||
var showConnectionsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Error dialog state for UiEvent.ShowError
|
||||
var showErrorDialog by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
val pendingIntentError = pendingIntentErrorMessage
|
||||
LaunchedEffect(pendingIntentError) {
|
||||
if (pendingIntentError != null) {
|
||||
@@ -616,11 +701,13 @@ class MainActivity :
|
||||
val dashboardUiState by dashboardViewModel.uiState.collectAsState()
|
||||
|
||||
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
|
||||
val isToolsSubScreen = currentRoute?.startsWith("tools/") == true
|
||||
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
|
||||
val isProfileRoute = currentRoute?.startsWith("profile/") == true
|
||||
val currentRootRoute =
|
||||
when {
|
||||
isSettingsSubScreen -> Screen.Settings.route
|
||||
isToolsSubScreen -> Screen.Tools.route
|
||||
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
|
||||
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
|
||||
isProfileRoute -> Screen.Dashboard.route
|
||||
@@ -630,7 +717,7 @@ class MainActivity :
|
||||
val isGroupsRoute = currentRootRoute == Screen.Groups.route
|
||||
val isLogRoute = currentRootRoute == Screen.Log.route
|
||||
|
||||
val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute
|
||||
val isSubScreen = isSettingsSubScreen || isToolsSubScreen || isConnectionsDetail || isProfileRoute
|
||||
// Get LogViewModel instance if we're on the Log screen
|
||||
val logViewModel: LogViewModel? =
|
||||
if (isLogRoute) {
|
||||
@@ -660,6 +747,14 @@ class MainActivity :
|
||||
null
|
||||
}
|
||||
|
||||
val isToolsRoute = currentRootRoute == Screen.Tools.route
|
||||
val tailscaleStatusViewModel: TailscaleStatusViewModel? =
|
||||
if (isToolsRoute) {
|
||||
viewModel()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val showGroupsInNav = dashboardUiState.hasGroups
|
||||
val showConnectionsInNav =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
@@ -674,6 +769,7 @@ class MainActivity :
|
||||
add(Screen.Connections)
|
||||
}
|
||||
add(Screen.Log)
|
||||
add(Screen.Tools)
|
||||
add(Screen.Settings)
|
||||
}
|
||||
|
||||
@@ -681,6 +777,7 @@ class MainActivity :
|
||||
buildSet {
|
||||
add(Screen.Dashboard.route)
|
||||
add(Screen.Log.route)
|
||||
add(Screen.Tools.route)
|
||||
add(Screen.Settings.route)
|
||||
if (useNavigationRail && showGroupsInNav) {
|
||||
add(Screen.Groups.route)
|
||||
@@ -739,24 +836,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
is UiEvent.RestartToTakeEffect -> {
|
||||
if (currentServiceStatus == Status.Started) {
|
||||
scope.launch {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Restart to take effect",
|
||||
actionLabel = "Restart",
|
||||
duration = androidx.compose.material3.SnackbarDuration.Short,
|
||||
)
|
||||
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Libbox.newStandaloneCommandClient().serviceReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -919,6 +999,17 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
||||
val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
||||
val toolsUnreadCount = crashReportUnreadCount + oomReportUnreadCount
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
CrashReportManager.refresh()
|
||||
OOMReportManager.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalTopBarController provides topBarController) {
|
||||
if (useNavigationRail) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -936,6 +1027,10 @@ class MainActivity :
|
||||
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
} else if (screen == Screen.Tools && toolsUnreadCount > 0) {
|
||||
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
@@ -980,6 +1075,10 @@ class MainActivity :
|
||||
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
} else if (screen == Screen.Tools && toolsUnreadCount > 0) {
|
||||
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
Icon(screen.icon, contentDescription = null)
|
||||
}
|
||||
@@ -1192,6 +1291,30 @@ class MainActivity :
|
||||
showBackgroundLocationDialog = true
|
||||
}
|
||||
|
||||
private suspend fun restartServiceForApplyChange() {
|
||||
if (currentServiceStatus != Status.Started) {
|
||||
return
|
||||
}
|
||||
|
||||
BoxService.stop()
|
||||
while (true) {
|
||||
when (currentServiceStatus) {
|
||||
Status.Stopped -> {
|
||||
startService()
|
||||
return
|
||||
}
|
||||
|
||||
Status.Starting -> {
|
||||
return
|
||||
}
|
||||
|
||||
Status.Started, Status.Stopping -> {
|
||||
delay(100L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection.disconnect()
|
||||
super.onDestroy()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.nekohasekai.sfa.compose.base
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
|
||||
@Composable
|
||||
fun rememberApplyServiceChangeNotifier(
|
||||
serviceStatus: Status,
|
||||
): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) {
|
||||
{ mode ->
|
||||
if (serviceStatus == Status.Started) {
|
||||
GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,12 @@ sealed class UiEvent {
|
||||
|
||||
object RequestReconnectService : UiEvent()
|
||||
|
||||
object RestartToTakeEffect : UiEvent()
|
||||
data class ApplyServiceChange(val mode: Mode) : UiEvent() {
|
||||
enum class Mode {
|
||||
Reload,
|
||||
Restart,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import io.nekohasekai.sfa.R
|
||||
|
||||
@@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I
|
||||
icon = Icons.Default.SwapVert,
|
||||
)
|
||||
|
||||
object Tools : Screen(
|
||||
route = "tools",
|
||||
titleRes = R.string.title_tools,
|
||||
icon = Icons.Default.Terminal,
|
||||
)
|
||||
|
||||
object Settings : Screen(
|
||||
route = "settings",
|
||||
titleRes = R.string.title_settings,
|
||||
@@ -46,5 +53,6 @@ val bottomNavigationScreens =
|
||||
listOf(
|
||||
Screen.Dashboard,
|
||||
Screen.Log,
|
||||
Screen.Tools,
|
||||
Screen.Settings,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,15 @@ import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen
|
||||
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
|
||||
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
|
||||
@@ -210,6 +219,111 @@ fun SFANavHost(
|
||||
}
|
||||
}
|
||||
|
||||
composable(Screen.Tools.route) {
|
||||
ToolsScreen(navController = navController)
|
||||
}
|
||||
|
||||
// Tools subscreens with slide animations
|
||||
composable(
|
||||
route = "tools/crash_report",
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
CrashReportListScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/crash_report/{reportId}",
|
||||
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
CrashReportDetailScreen(navController = navController, reportId = reportId)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/crash_report/{reportId}/metadata",
|
||||
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
CrashReportMetadataScreen(navController = navController, reportId = reportId)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/crash_report/{reportId}/file/{fileKind}",
|
||||
arguments = listOf(
|
||||
navArgument("reportId") { type = NavType.StringType },
|
||||
navArgument("fileKind") { type = NavType.StringType },
|
||||
),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable
|
||||
CrashReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/oom_report",
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
OOMReportListScreen(navController = navController, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/oom_report/{reportId}",
|
||||
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
OOMReportDetailScreen(navController = navController, reportId = reportId)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/oom_report/{reportId}/metadata",
|
||||
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
OOMReportMetadataScreen(navController = navController, reportId = reportId)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "tools/oom_report/{reportId}/file/{fileKind}",
|
||||
arguments = listOf(
|
||||
navArgument("reportId") { type = NavType.StringType },
|
||||
navArgument("fileKind") { type = NavType.StringType },
|
||||
),
|
||||
enterTransition = slideInFromRight,
|
||||
exitTransition = slideOutToLeft,
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) { backStackEntry ->
|
||||
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
|
||||
val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable
|
||||
OOMReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind)
|
||||
}
|
||||
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(navController = navController)
|
||||
}
|
||||
@@ -222,7 +336,7 @@ fun SFANavHost(
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
AppSettingsScreen(navController = navController)
|
||||
AppSettingsScreen(navController = navController, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -252,7 +366,7 @@ fun SFANavHost(
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
ServiceSettingsScreen(navController = navController)
|
||||
ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -262,7 +376,7 @@ fun SFANavHost(
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
ProfileOverrideScreen(navController = navController)
|
||||
ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -272,7 +386,7 @@ fun SFANavHost(
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
PerAppProxyScreen(onBack = { navController.navigateUp() })
|
||||
PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -292,7 +406,7 @@ fun SFANavHost(
|
||||
popEnterTransition = slideInFromLeft,
|
||||
popExitTransition = slideOutToRight,
|
||||
) {
|
||||
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() })
|
||||
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
|
||||
}
|
||||
|
||||
composable(
|
||||
|
||||
@@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.shared.AppSelectionCard
|
||||
import io.nekohasekai.sfa.compose.shared.PackageCache
|
||||
import io.nekohasekai.sfa.compose.shared.SortMode
|
||||
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.clipboardText
|
||||
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
|
||||
@@ -95,10 +98,14 @@ private enum class RiskCategory {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
fun PrivilegeSettingsManageScreen(
|
||||
onBack: () -> Unit,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
|
||||
var sortMode by remember { mutableStateOf(SortMode.NAME) }
|
||||
var sortReverse by remember { mutableStateOf(false) }
|
||||
@@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
|
||||
}
|
||||
if (failure != null) {
|
||||
syncErrorMessage = failure.message ?: failure.toString()
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.shared.AppSelectionCard
|
||||
import io.nekohasekai.sfa.compose.shared.PackageCache
|
||||
import io.nekohasekai.sfa.compose.shared.SortMode
|
||||
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.clipboardText
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
@@ -106,10 +109,14 @@ private sealed class ScanResult {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
fun PerAppProxyScreen(
|
||||
onBack: () -> Unit,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
|
||||
var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) }
|
||||
var sortMode by remember { mutableStateOf(SortMode.NAME) }
|
||||
@@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
|
||||
fun saveSelectedApplications(newUids: Set<Int>) {
|
||||
coroutineScope.launch {
|
||||
Settings.perAppProxyList = buildPackageList(newUids)
|
||||
withContext(Dispatchers.IO) {
|
||||
Settings.perAppProxyList = buildPackageList(newUids)
|
||||
}
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
|
||||
onModeChange = { mode ->
|
||||
proxyMode = mode
|
||||
coroutineScope.launch {
|
||||
Settings.perAppProxyMode = mode
|
||||
withContext(Dispatchers.IO) {
|
||||
Settings.perAppProxyMode = mode
|
||||
}
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
},
|
||||
onSortModeChange = { mode ->
|
||||
|
||||
@@ -87,8 +87,11 @@ import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.clipboardText
|
||||
import io.nekohasekai.sfa.update.UpdateCheckException
|
||||
@@ -109,7 +112,10 @@ import android.provider.Settings as AndroidSettings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(navController: NavController) {
|
||||
fun AppSettingsScreen(
|
||||
navController: NavController,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_app_settings)) },
|
||||
@@ -155,6 +161,7 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
var notificationEnabled by remember { mutableStateOf(true) }
|
||||
var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) }
|
||||
var showDisableNotificationDialog by remember { mutableStateOf(false) }
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
|
||||
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||
val availableLocales = remember { getSupportedLocales(context) }
|
||||
@@ -679,6 +686,9 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
dynamicNotification = checked
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.dynamicNotification = checked
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -62,9 +62,9 @@ import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
@@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
val systemHookStatus by HookStatusClient.status.collectAsState()
|
||||
var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) }
|
||||
|
||||
@@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
messageDialogTitle = context.getString(R.string.error_title)
|
||||
messageDialogMessage = failure.message ?: failure.toString()
|
||||
showMessageDialog = true
|
||||
} else if (serviceStatus == Status.Started) {
|
||||
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
messageDialogTitle = context.getString(R.string.error_title)
|
||||
messageDialogMessage = failure.message ?: failure.toString()
|
||||
showMessageDialog = true
|
||||
} else if (checked && serviceStatus == Status.Started) {
|
||||
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
|
||||
messageDialogTitle = context.getString(R.string.error_title)
|
||||
messageDialogMessage = failure.message ?: failure.toString()
|
||||
showMessageDialog = true
|
||||
} else if (serviceStatus == Status.Started) {
|
||||
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.RootClient
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.vendor.PackageQueryManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileOverrideScreen(navController: NavController) {
|
||||
fun ProfileOverrideScreen(
|
||||
navController: NavController,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.profile_override)) },
|
||||
@@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) }
|
||||
var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) }
|
||||
var isScanning by remember { mutableStateOf(false) }
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
|
||||
fun scanAndSaveManagedList() {
|
||||
fun scanAndSaveManagedList(shouldNotify: Boolean = false) {
|
||||
isScanning = true
|
||||
scope.launch {
|
||||
val chinaApps = PerAppProxyScanner.scanAllChinaApps()
|
||||
@@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Settings.perAppProxyManagedList = chinaApps
|
||||
}
|
||||
isScanning = false
|
||||
if (shouldNotify) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Settings.perAppProxyEnabled = true
|
||||
}
|
||||
if (managedModeEnabled) {
|
||||
scanAndSaveManagedList()
|
||||
scanAndSaveManagedList(shouldNotify = true)
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Settings.autoRedirect = true
|
||||
}
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
@@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
autoRedirect = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.autoRedirect = false
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
perAppProxyEnabled = checked
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.perAppProxyEnabled = checked
|
||||
if (!checked || !managedModeEnabled) {
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (checked && managedModeEnabled) {
|
||||
scanAndSaveManagedList()
|
||||
scanAndSaveManagedList(shouldNotify = true)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.perAppProxyManagedMode = true
|
||||
}
|
||||
scanAndSaveManagedList()
|
||||
scanAndSaveManagedList(shouldNotify = true)
|
||||
} else {
|
||||
managedModeEnabled = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.perAppProxyManagedMode = false
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
perAppProxyEnabled = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.perAppProxyEnabled = true
|
||||
if (!managedModeEnabled) {
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (managedModeEnabled) {
|
||||
scanAndSaveManagedList()
|
||||
scanAndSaveManagedList(shouldNotify = true)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Settings.perAppProxyEnabled = true
|
||||
}
|
||||
if (managedModeEnabled) {
|
||||
scanAndSaveManagedList()
|
||||
scanAndSaveManagedList(shouldNotify = true)
|
||||
} else {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
} else {
|
||||
showRootDialog = false
|
||||
@@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
Settings.perAppProxyEnabled = false
|
||||
}
|
||||
}
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
showModeDialog = false
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
@@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT
|
||||
}
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
showModeDialog = false
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
|
||||
@@ -57,15 +57,23 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.ServiceConnection
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import io.nekohasekai.sfa.ktx.launchCustomTab
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) {
|
||||
fun ServiceSettingsScreen(
|
||||
navController: NavController,
|
||||
serviceConnection: ServiceConnection? = null,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.service)) },
|
||||
@@ -84,6 +92,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
|
||||
val scope = rememberCoroutineScope()
|
||||
var isBatteryOptimizationIgnored by remember { mutableStateOf(false) }
|
||||
var allowBypass by remember { mutableStateOf(Settings.allowBypass) }
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
val requestBatteryOptimizationLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
@@ -255,6 +264,9 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
|
||||
allowBypass = checked
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.allowBypass = checked
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.Share
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
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
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.CrashReport
|
||||
import io.nekohasekai.sfa.bg.CrashReportFile
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.DateFormat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashReportDetailScreen(navController: NavController, reportId: String) {
|
||||
val reports by CrashReportManager.reports.collectAsState()
|
||||
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) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(report) {
|
||||
if (report != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
files = CrashReportManager.availableFiles(report)
|
||||
}
|
||||
CrashReportManager.markAsRead(report)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
val title = if (report != null) {
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
|
||||
} else {
|
||||
reportId
|
||||
}
|
||||
|
||||
val hasConfig = report != null && CrashReportManager.hasConfigFile(report)
|
||||
|
||||
fun shareReport(includeConfig: Boolean) {
|
||||
val currentReport = report ?: return
|
||||
scope.launch {
|
||||
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
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 = {
|
||||
scope.launch {
|
||||
if (report != null) {
|
||||
CrashReportManager.delete(report)
|
||||
}
|
||||
navController.navigateUp()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (files.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_section_files),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
files.forEachIndexed { index, file ->
|
||||
val shape = when {
|
||||
files.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == files.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
val icon = when (file.kind) {
|
||||
CrashReportFile.Kind.METADATA -> Icons.Default.DataObject
|
||||
CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal
|
||||
CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport
|
||||
CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
file.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.clickable {
|
||||
if (file.kind == CrashReportFile.Kind.METADATA) {
|
||||
navController.navigate("tools/crash_report/$reportId/metadata")
|
||||
} else {
|
||||
navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}")
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashReportMetadataScreen(navController: NavController, reportId: String) {
|
||||
val reports by CrashReportManager.reports.collectAsState()
|
||||
val report = reports.find { it.id == reportId }
|
||||
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(report) {
|
||||
if (report != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
entries = loadMetadataEntries(report)
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.report_metadata)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (entries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
entries.forEachIndexed { index, (key, value) ->
|
||||
val shape = when {
|
||||
entries.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
topEnd = 12.dp,
|
||||
)
|
||||
index == entries.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
key,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(value)
|
||||
},
|
||||
modifier = Modifier.clip(shape),
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMetadataEntries(report: CrashReport): List<Pair<String, String>> {
|
||||
val metadataFile = CrashReportManager.availableFiles(report)
|
||||
.find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList()
|
||||
val content = metadataFile.file.readText()
|
||||
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
|
||||
return json.keys().asSequence()
|
||||
.mapNotNull { key ->
|
||||
val value = json.optString(key, "")
|
||||
if (value.isNotBlank()) key to value else null
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
|
||||
val reports by CrashReportManager.reports.collectAsState()
|
||||
val report = reports.find { it.id == reportId }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull()
|
||||
val displayName = when (kind) {
|
||||
CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log)
|
||||
CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log)
|
||||
CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration)
|
||||
else -> fileKind
|
||||
}
|
||||
|
||||
LaunchedEffect(report, kind) {
|
||||
if (report != null && kind != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = CrashReportManager.availableFiles(report).find { it.kind == kind }
|
||||
content = if (file != null) CrashReportManager.loadFileContent(file) else ""
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(displayName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (content.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.DeleteSweep
|
||||
import androidx.compose.material.icons.outlined.FlashOn
|
||||
import androidx.compose.material3.Badge
|
||||
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
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.BuildConfig
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashReportListScreen(navController: NavController) {
|
||||
val reports by CrashReportManager.reports.collectAsState()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var crashTriggerExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
CrashReportManager.refresh()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.crash_report)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (reports.isNotEmpty() || BuildConfig.DEBUG) {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = {
|
||||
menuExpanded = false
|
||||
crashTriggerExpanded = false
|
||||
},
|
||||
) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Crash Trigger") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.FlashOn,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = { crashTriggerExpanded = !crashTriggerExpanded },
|
||||
)
|
||||
if (crashTriggerExpanded) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Go Crash",
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
crashTriggerExpanded = false
|
||||
Libbox.triggerGoPanic()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Native Crash",
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
crashTriggerExpanded = false
|
||||
Thread {
|
||||
Thread.sleep(200)
|
||||
throw RuntimeException("debug native crash")
|
||||
}.start()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (reports.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.report_delete_all),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch {
|
||||
CrashReportManager.deleteAll()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_section_reports),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
if (reports.isEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
reports.forEachIndexed { index, report ->
|
||||
val shape = when {
|
||||
reports.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == reports.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
formatDate(report.date),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
leadingContent = if (!report.isRead) {
|
||||
{
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.clickable {
|
||||
navController.navigate("tools/crash_report/${report.id}")
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.crash_report_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
|
||||
@@ -0,0 +1,451 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.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
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.OOMReport
|
||||
import io.nekohasekai.sfa.bg.OOMReportFile
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.DateFormat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OOMReportDetailScreen(navController: NavController, reportId: String) {
|
||||
val reports by OOMReportManager.reports.collectAsState()
|
||||
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) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(report) {
|
||||
if (report != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
files = OOMReportManager.availableFiles(report)
|
||||
}
|
||||
OOMReportManager.markAsRead(report)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
val title = if (report != null) {
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
|
||||
} else {
|
||||
reportId
|
||||
}
|
||||
|
||||
val hasConfig = report != null && OOMReportManager.hasConfigFile(report)
|
||||
|
||||
fun shareReport(includeConfig: Boolean) {
|
||||
val currentReport = report ?: return
|
||||
scope.launch {
|
||||
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
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 = {
|
||||
scope.launch {
|
||||
if (report != null) {
|
||||
OOMReportManager.delete(report)
|
||||
}
|
||||
navController.navigateUp()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (files.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_section_files),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
files.forEachIndexed { index, file ->
|
||||
val shape = when {
|
||||
files.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == files.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
val icon = when (file.kind) {
|
||||
OOMReportFile.Kind.METADATA -> Icons.Default.DataObject
|
||||
OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings
|
||||
OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
file.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (file.kind != OOMReportFile.Kind.PROFILE) {
|
||||
Modifier.clickable {
|
||||
if (file.kind == OOMReportFile.Kind.METADATA) {
|
||||
navController.navigate("tools/oom_report/$reportId/metadata")
|
||||
} else {
|
||||
navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OOMReportMetadataScreen(navController: NavController, reportId: String) {
|
||||
val reports by OOMReportManager.reports.collectAsState()
|
||||
val report = reports.find { it.id == reportId }
|
||||
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(report) {
|
||||
if (report != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
entries = loadOOMMetadataEntries(report)
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.report_metadata)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (entries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
entries.forEachIndexed { index, (key, value) ->
|
||||
val shape = when {
|
||||
entries.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == entries.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(key, style = MaterialTheme.typography.bodyLarge)
|
||||
},
|
||||
supportingContent = { Text(value) },
|
||||
modifier = Modifier.clip(shape),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOOMMetadataEntries(report: OOMReport): List<Pair<String, String>> {
|
||||
val metadataFile = OOMReportManager.availableFiles(report)
|
||||
.find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList()
|
||||
val content = metadataFile.file.readText()
|
||||
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
|
||||
return json.keys().asSequence()
|
||||
.mapNotNull { key ->
|
||||
val value = json.optString(key, "")
|
||||
if (value.isNotBlank()) key to value else null
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
|
||||
val reports by OOMReportManager.reports.collectAsState()
|
||||
val report = reports.find { it.id == reportId }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var displayName by remember { mutableStateOf(fileKind) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull()
|
||||
|
||||
LaunchedEffect(report, kind) {
|
||||
if (report != null && kind != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = OOMReportManager.availableFiles(report).find { it.kind == kind }
|
||||
if (file != null) {
|
||||
displayName = file.displayName
|
||||
content = OOMReportManager.loadFileContent(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(displayName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (content.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.DeleteSweep
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
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
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.libbox.Libbox
|
||||
import io.nekohasekai.sfa.Application
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
import io.nekohasekai.sfa.constant.Status
|
||||
import io.nekohasekai.sfa.database.Settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OOMReportListScreen(
|
||||
navController: NavController,
|
||||
serviceStatus: Status = Status.Stopped,
|
||||
) {
|
||||
val reports by OOMReportManager.reports.collectAsState()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||
|
||||
var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) }
|
||||
var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) }
|
||||
var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) }
|
||||
var showMemoryLimitDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
OOMReportManager.refresh()
|
||||
val storedLimit = Settings.oomMemoryLimitMB
|
||||
if (!memoryLimitOptions.contains(storedLimit)) {
|
||||
oomMemoryLimitMB = memoryLimitOptions.first()
|
||||
Settings.oomMemoryLimitMB = oomMemoryLimitMB
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.oom_report)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.oom_report_fetch)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
if (serviceStatus != Status.Started) {
|
||||
errorMessage =
|
||||
Application.application.getString(R.string.service_not_started)
|
||||
} else {
|
||||
scope.launch {
|
||||
val failure =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Libbox.newStandaloneCommandClient().triggerOOMReport()
|
||||
}.exceptionOrNull()
|
||||
}
|
||||
if (failure != null) {
|
||||
errorMessage = failure.message ?: failure.toString()
|
||||
} else {
|
||||
delay(1000)
|
||||
withContext(Dispatchers.IO) {
|
||||
OOMReportManager.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if (reports.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.report_delete_all),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch { OOMReportManager.deleteAll() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// Reports section
|
||||
Text(
|
||||
stringResource(R.string.report_section_reports),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
if (reports.isEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.report_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
reports.forEachIndexed { index, report ->
|
||||
val shape = when {
|
||||
reports.size == 1 -> RoundedCornerShape(12.dp)
|
||||
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
index == reports.lastIndex -> RoundedCornerShape(
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp,
|
||||
)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
formatDate(report.date),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
leadingContent = if (!report.isRead) {
|
||||
{
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.clickable {
|
||||
navController.navigate("tools/oom_report/${report.id}")
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.oom_report_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
// Settings section
|
||||
Text(
|
||||
stringResource(R.string.title_settings),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report_enable_memory_limit),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(stringResource(R.string.oom_report_enable_memory_limit_description))
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = oomKillerEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
oomKillerEnabled = checked
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.oomKillerEnabled = checked
|
||||
Application.application.reloadSetupOptions()
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
AnimatedVisibility(visible = oomKillerEnabled) {
|
||||
Column {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report_memory_limit),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L))
|
||||
},
|
||||
modifier = Modifier.clickable { showMemoryLimitDialog = true },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report_kill_connections),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(stringResource(R.string.oom_report_kill_connections_description))
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = oomKillerKillConnections,
|
||||
onCheckedChange = { checked ->
|
||||
oomKillerKillConnections = checked
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.oomKillerDisabled = !checked
|
||||
Application.application.reloadSetupOptions()
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { message ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { errorMessage = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { errorMessage = null }) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
text = { Text(message) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showMemoryLimitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showMemoryLimitDialog = false },
|
||||
title = { Text(stringResource(R.string.oom_report_memory_limit)) },
|
||||
text = {
|
||||
Column {
|
||||
memoryLimitOptions.forEach { value ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L))
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = value == oomMemoryLimitMB,
|
||||
onClick = null,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
oomMemoryLimitMB = value
|
||||
showMemoryLimitDialog = false
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Settings.oomMemoryLimitMB = value
|
||||
Application.application.reloadSetupOptions()
|
||||
withContext(Dispatchers.Main) {
|
||||
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
|
||||
@@ -0,0 +1,127 @@
|
||||
package io.nekohasekai.sfa.compose.screen.tools
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import io.nekohasekai.sfa.R
|
||||
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ToolsScreen(navController: NavController) {
|
||||
OverrideTopBar {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_tools)) },
|
||||
)
|
||||
}
|
||||
|
||||
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
|
||||
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_debug),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.crash_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BugReport,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (crashUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$crashUnreadCount")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/crash_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.oom_report),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Memory,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (oomUnreadCount > 0) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||
Text("$oomUnreadCount")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
|
||||
.clickable { navController.navigate("tools/oom_report") },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ object SettingsKey {
|
||||
const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled"
|
||||
const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix"
|
||||
|
||||
// OOM killer
|
||||
const val OOM_KILLER_ENABLED = "oom_killer_enabled"
|
||||
const val OOM_KILLER_DISABLED = "oom_killer_disabled"
|
||||
const val OOM_MEMORY_LIMIT_MB = "oom_memory_limit_mb"
|
||||
|
||||
// dashboard
|
||||
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
|
||||
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
|
||||
|
||||
@@ -106,6 +106,10 @@ object Settings {
|
||||
) { false }
|
||||
var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" }
|
||||
|
||||
var oomKillerEnabled by dataStore.boolean(SettingsKey.OOM_KILLER_ENABLED) { false }
|
||||
var oomKillerDisabled by dataStore.boolean(SettingsKey.OOM_KILLER_DISABLED) { true }
|
||||
var oomMemoryLimitMB by dataStore.int(SettingsKey.OOM_MEMORY_LIMIT_MB) { 50 }
|
||||
|
||||
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
|
||||
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
||||
|
||||
|
||||
@@ -406,6 +406,37 @@
|
||||
<string name="content_description_collapse_search">جمع کردن جستجو</string>
|
||||
<string name="content_description_search_logs">جستجوی لاگها</string>
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">ابزارها</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">خالی</string>
|
||||
<string name="report_section_reports">گزارشها</string>
|
||||
<string name="report_section_files">فایلها</string>
|
||||
<string name="report_delete_all">حذف همه</string>
|
||||
<string name="report_delete">حذف</string>
|
||||
<string name="report_share">اشتراکگذاری</string>
|
||||
<string name="report_share_with_config">اشتراکگذاری با پیکربندی</string>
|
||||
<string name="report_metadata">فراداده</string>
|
||||
<string name="report_configuration">پیکربندی</string>
|
||||
<string name="report_origin_local">محلی</string>
|
||||
<string name="service_not_started">سرویس شروع نشده است</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">گزارش خرابی</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">گزارش کمبود حافظه</string>
|
||||
<string name="oom_report_description">هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین میتوانید جمعآوری گزارش را به صورت دستی فعال کنید.</string>
|
||||
<string name="oom_report_fetch">دریافت گزارش حافظه</string>
|
||||
<string name="oom_report_enable_memory_limit">فعالسازی محدودیت حافظه</string>
|
||||
<string name="oom_report_enable_memory_limit_description">یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند.</string>
|
||||
<string name="oom_report_memory_limit">محدودیت حافظه</string>
|
||||
<string name="oom_report_kill_connections">قطع اتصالات</string>
|
||||
<string name="oom_report_kill_connections_description">هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید.</string>
|
||||
|
||||
<!-- Xposed Module -->
|
||||
<string name="xposed_description">بهبود دسترسی ویژه برای sing-box</string>
|
||||
|
||||
|
||||
@@ -412,6 +412,37 @@
|
||||
<string name="content_description_collapse_search">Свернуть поиск</string>
|
||||
<string name="content_description_search_logs">Поиск в логе</string>
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">Инструменты</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">Пусто</string>
|
||||
<string name="report_section_reports">Отчёты</string>
|
||||
<string name="report_section_files">Файлы</string>
|
||||
<string name="report_delete_all">Удалить все</string>
|
||||
<string name="report_delete">Удалить</string>
|
||||
<string name="report_share">Поделиться</string>
|
||||
<string name="report_share_with_config">Поделиться с конфигурацией</string>
|
||||
<string name="report_metadata">Метаданные</string>
|
||||
<string name="report_configuration">Конфигурация</string>
|
||||
<string name="report_origin_local">Локальный</string>
|
||||
<string name="service_not_started">Служба не запущена</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">Отчёт о сбое</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">Отчёт о нехватке памяти</string>
|
||||
<string name="oom_report_description">При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта.</string>
|
||||
<string name="oom_report_fetch">Получить отчёт о памяти</string>
|
||||
<string name="oom_report_enable_memory_limit">Включить ограничение памяти</string>
|
||||
<string name="oom_report_enable_memory_limit_description">Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения.</string>
|
||||
<string name="oom_report_memory_limit">Ограничение памяти</string>
|
||||
<string name="oom_report_kill_connections">Завершить соединения</string>
|
||||
<string name="oom_report_kill_connections_description">Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса.</string>
|
||||
|
||||
<!-- Xposed Module -->
|
||||
<string name="xposed_description">Привилегированное расширение для sing-box</string>
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="action">操作</string>
|
||||
<string name="action_start">启动</string>
|
||||
<string name="action_deselect">取消选择</string>
|
||||
<string name="action_reload">重载</string>
|
||||
<string name="action_restart">重启</string>
|
||||
<string name="expand">展开</string>
|
||||
<string name="collapse">收起</string>
|
||||
<string name="expand_all">全部展开</string>
|
||||
@@ -421,6 +423,40 @@
|
||||
<string name="content_description_collapse_search">折叠搜索</string>
|
||||
<string name="content_description_search_logs">搜索日志</string>
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">工具</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">空</string>
|
||||
<string name="report_section_reports">报告</string>
|
||||
<string name="report_section_files">文件</string>
|
||||
<string name="report_delete_all">全部删除</string>
|
||||
<string name="report_delete">删除</string>
|
||||
<string name="report_share">分享</string>
|
||||
<string name="report_share_with_config">附带配置分享</string>
|
||||
<string name="report_metadata">元数据</string>
|
||||
<string name="report_configuration">配置</string>
|
||||
<string name="report_origin_local">本地</string>
|
||||
<string name="service_not_started">服务未启动</string>
|
||||
<string name="service_reload_required">需要重载服务以应用更改</string>
|
||||
<string name="service_restart_required">需要重启服务以应用更改</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">崩溃报告</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
<string name="crash_report_description">当遇到崩溃时,您将会收到报告。</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">内存不足报告</string>
|
||||
<string name="oom_report_description">启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。</string>
|
||||
<string name="oom_report_fetch">获取内存报告</string>
|
||||
<string name="oom_report_enable_memory_limit">启用内存限制</string>
|
||||
<string name="oom_report_enable_memory_limit_description">为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。</string>
|
||||
<string name="oom_report_memory_limit">内存限制</string>
|
||||
<string name="oom_report_kill_connections">终止连接</string>
|
||||
<string name="oom_report_kill_connections_description">当服务内存超出限制时,终止所有连接以释放内存。</string>
|
||||
|
||||
<!-- Xposed Module -->
|
||||
<string name="xposed_description">sing-box 的特权增强</string>
|
||||
<!-- Privileged Enhancement -->
|
||||
|
||||
@@ -424,6 +424,37 @@
|
||||
<string name="content_description_collapse_search">收合搜尋</string>
|
||||
<string name="content_description_search_logs">搜尋日誌</string>
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">工具</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">空</string>
|
||||
<string name="report_section_reports">報告</string>
|
||||
<string name="report_section_files">檔案</string>
|
||||
<string name="report_delete_all">全部刪除</string>
|
||||
<string name="report_delete">刪除</string>
|
||||
<string name="report_share">分享</string>
|
||||
<string name="report_share_with_config">附帶配置分享</string>
|
||||
<string name="report_metadata">元數據</string>
|
||||
<string name="report_configuration">配置</string>
|
||||
<string name="report_origin_local">本地</string>
|
||||
<string name="service_not_started">服務未啟動</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">當機報告</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">記憶體不足報告</string>
|
||||
<string name="oom_report_description">啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。</string>
|
||||
<string name="oom_report_fetch">取得記憶體報告</string>
|
||||
<string name="oom_report_enable_memory_limit">啟用記憶體限制</string>
|
||||
<string name="oom_report_enable_memory_limit_description">為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。</string>
|
||||
<string name="oom_report_memory_limit">記憶體限制</string>
|
||||
<string name="oom_report_kill_connections">終止連線</string>
|
||||
<string name="oom_report_kill_connections_description">當服務記憶體超出限制時,終止所有連線以釋放記憶體。</string>
|
||||
|
||||
<!-- Xposed Module -->
|
||||
<string name="xposed_description">sing-box 的特權強化</string>
|
||||
<!-- Privileged Enhancement -->
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="action">Action</string>
|
||||
<string name="action_start">Start</string>
|
||||
<string name="action_deselect">Deselect</string>
|
||||
<string name="action_reload">Reload</string>
|
||||
<string name="action_restart">Restart</string>
|
||||
<string name="expand">Expand</string>
|
||||
<string name="collapse">Collapse</string>
|
||||
<string name="expand_all">Expand All</string>
|
||||
@@ -424,6 +426,40 @@
|
||||
<string name="content_description_collapse_search">Collapse search</string>
|
||||
<string name="content_description_search_logs">Search logs</string>
|
||||
|
||||
<!-- Tools -->
|
||||
<string name="title_tools">Tools</string>
|
||||
|
||||
<!-- Shared Report -->
|
||||
<string name="report_empty">Empty</string>
|
||||
<string name="report_section_reports">Reports</string>
|
||||
<string name="report_section_files">Files</string>
|
||||
<string name="report_delete_all">Delete All</string>
|
||||
<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_metadata">Metadata</string>
|
||||
<string name="report_configuration">Configuration</string>
|
||||
<string name="report_origin_local">Local</string>
|
||||
<string name="service_not_started">Service not started</string>
|
||||
<string name="service_reload_required">Reload service to apply changes</string>
|
||||
<string name="service_restart_required">Restart service to apply changes</string>
|
||||
|
||||
<!-- Crash Report -->
|
||||
<string name="crash_report">Crash Report</string>
|
||||
<string name="crash_report_go_log">Go Crash Log</string>
|
||||
<string name="crash_report_jvm_log">JVM Crash Log</string>
|
||||
<string name="crash_report_description">You will receive a report when a crash occurs.</string>
|
||||
|
||||
<!-- OOM Report -->
|
||||
<string name="oom_report">OOM Report</string>
|
||||
<string name="oom_report_description">When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection.</string>
|
||||
<string name="oom_report_fetch">Fetch Memory Report</string>
|
||||
<string name="oom_report_enable_memory_limit">Enable Memory Limit</string>
|
||||
<string name="oom_report_enable_memory_limit_description">Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit.</string>
|
||||
<string name="oom_report_memory_limit">Memory Limit</string>
|
||||
<string name="oom_report_kill_connections">Kill Connections</string>
|
||||
<string name="oom_report_kill_connections_description">Kill all connections to free memory when the service memory exceeds the limit.</string>
|
||||
|
||||
<!-- Xposed Module -->
|
||||
<string name="xposed_description">Privileged Enhancement for sing-box</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user