From b3b09454c06bd04baad363f119c6a65ed7b6e301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 11:35:49 +0800 Subject: [PATCH] Tools View & Crash Report & OOM Report --- .../java/io/nekohasekai/sfa/Application.kt | 56 ++- .../java/io/nekohasekai/sfa/bg/BoxService.kt | 7 + .../nekohasekai/sfa/bg/CrashReportManager.kt | 251 ++++++++++ .../io/nekohasekai/sfa/bg/OOMReportManager.kt | 165 +++++++ .../nekohasekai/sfa/compose/MainActivity.kt | 165 ++++++- .../base/ApplyServiceChangeNotifier.kt | 16 + .../nekohasekai/sfa/compose/base/UiEvent.kt | 7 +- .../navigation/NavigationDestinations.kt | 8 + .../sfa/compose/navigation/SFANavigation.kt | 124 ++++- .../PrivilegeSettingsManageScreen.kt | 11 +- .../profileoverride/PerAppProxyScreen.kt | 19 +- .../screen/settings/AppSettingsScreen.kt | 12 +- .../settings/PrivilegeSettingsScreen.kt | 15 +- .../screen/settings/ProfileOverrideScreen.kt | 47 +- .../screen/settings/ServiceSettingsScreen.kt | 14 +- .../screen/tools/CrashReportDetailScreen.kt | 459 ++++++++++++++++++ .../screen/tools/CrashReportListScreen.kt | 263 ++++++++++ .../screen/tools/OOMReportDetailScreen.kt | 451 +++++++++++++++++ .../screen/tools/OOMReportListScreen.kt | 416 ++++++++++++++++ .../sfa/compose/screen/tools/ToolsScreen.kt | 127 +++++ .../nekohasekai/sfa/constant/SettingsKey.kt | 5 + .../io/nekohasekai/sfa/database/Settings.kt | 4 + app/src/main/res/values-fa/strings.xml | 31 ++ app/src/main/res/values-ru-rRU/strings.xml | 31 ++ app/src/main/res/values-zh-rCN/strings.xml | 36 ++ app/src/main/res/values-zh-rTW/strings.xml | 31 ++ app/src/main/res/values/strings.xml | 36 ++ 27 files changed, 2743 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 02b2467..1250f80 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -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 { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index a354866..da211c8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -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!!) } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt new file mode 100644 index 0000000..cb2a27b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -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>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _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 { + 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 { + val files = mutableListOf() + 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) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt new file mode 100644 index 0000000..183b19e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -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>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _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 { + 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 { + val files = mutableListOf() + 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 + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 56e8639..059c64d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -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(null) } + var activeApplyServiceChangeMode by remember { mutableStateOf(null) } + var applyServiceChangeJob by remember { mutableStateOf(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() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt new file mode 100644 index 0000000..7f31617 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 6b7467a..70b1f3c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -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, + } + } } /** diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 27456b9..9ae23cc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -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, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index d6e5f22..e52ee71 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -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( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt index 8734b43..46b170f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -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) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt index 74b8986..3c553b2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -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) { 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 -> diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 9c94f63..028d021 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -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) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt index dbcf6bc..0187c20 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -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) } } }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 22370e8..6d1688b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -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( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index b6038d9..2171997 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -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) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt new file mode 100644 index 0000000..bd7d712 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -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>(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>>(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> { + 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), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt new file mode 100644 index 0000000..aa238a0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt @@ -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) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt new file mode 100644 index 0000000..f7936ac --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -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>(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>>(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> { + 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), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt new file mode 100644 index 0000000..94baff3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt @@ -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(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) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt new file mode 100644 index 0000000..fc3f081 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 2f41fea..8454278 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -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" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 64f417f..721ac01 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -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() } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index db997c6..d97cf2e 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -406,6 +406,37 @@ جمع کردن جستجو جستجوی لاگ‌ها + + ابزارها + + + خالی + گزارش‌ها + فایل‌ها + حذف همه + حذف + اشتراک‌گذاری + اشتراک‌گذاری با پیکربندی + فراداده + پیکربندی + محلی + سرویس شروع نشده است + + + گزارش خرابی + Go Crash Log + JVM Crash Log + + + گزارش کمبود حافظه + هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید. + دریافت گزارش حافظه + فعال‌سازی محدودیت حافظه + یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند. + محدودیت حافظه + قطع اتصالات + هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید. + بهبود دسترسی ویژه برای sing-box diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 274a39d..15ec972 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -412,6 +412,37 @@ Свернуть поиск Поиск в логе + + Инструменты + + + Пусто + Отчёты + Файлы + Удалить все + Удалить + Поделиться + Поделиться с конфигурацией + Метаданные + Конфигурация + Локальный + Служба не запущена + + + Отчёт о сбое + Go Crash Log + JVM Crash Log + + + Отчёт о нехватке памяти + При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта. + Получить отчёт о памяти + Включить ограничение памяти + Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения. + Ограничение памяти + Завершить соединения + Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса. + Привилегированное расширение для sing-box diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f7df1a..ff99bd0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,6 +23,8 @@ 操作 启动 取消选择 + 重载 + 重启 展开 收起 全部展开 @@ -421,6 +423,40 @@ 折叠搜索 搜索日志 + + 工具 + + + + 报告 + 文件 + 全部删除 + 删除 + 分享 + 附带配置分享 + 元数据 + 配置 + 本地 + 服务未启动 + 需要重载服务以应用更改 + 需要重启服务以应用更改 + + + 崩溃报告 + Go Crash Log + JVM Crash Log + 当遇到崩溃时,您将会收到报告。 + + + 内存不足报告 + 启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。 + 获取内存报告 + 启用内存限制 + 为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。 + 内存限制 + 终止连接 + 当服务内存超出限制时,终止所有连接以释放内存。 + sing-box 的特权增强 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e0d79aa..7ebd5a1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -424,6 +424,37 @@ 收合搜尋 搜尋日誌 + + 工具 + + + + 報告 + 檔案 + 全部刪除 + 刪除 + 分享 + 附帶配置分享 + 元數據 + 配置 + 本地 + 服務未啟動 + + + 當機報告 + Go Crash Log + JVM Crash Log + + + 記憶體不足報告 + 啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。 + 取得記憶體報告 + 啟用記憶體限制 + 為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。 + 記憶體限制 + 終止連線 + 當服務記憶體超出限制時,終止所有連線以釋放記憶體。 + sing-box 的特權強化 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 064ccb7..633755b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Action Start Deselect + Reload + Restart Expand Collapse Expand All @@ -424,6 +426,40 @@ Collapse search Search logs + + Tools + + + Empty + Reports + Files + Delete All + Delete + Share + Share With Configuration + Metadata + Configuration + Local + Service not started + Reload service to apply changes + Restart service to apply changes + + + Crash Report + Go Crash Log + JVM Crash Log + You will receive a report when a crash occurs. + + + OOM Report + When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection. + Fetch Memory Report + Enable Memory Limit + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + Memory Limit + Kill Connections + Kill all connections to free memory when the service memory exceeds the limit. + Privileged Enhancement for sing-box