Tools View & Crash Report & OOM Report

This commit is contained in:
世界
2026-07-06 21:15:22 +08:00
parent 78c5527a3d
commit 2d1277a36b
27 changed files with 2743 additions and 64 deletions
@@ -10,12 +10,14 @@ import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.PowerManager
import androidx.core.content.getSystemService
import go.Seq
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.SetupOptions
import io.nekohasekai.sfa.bg.AppChangeReceiver
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.bg.UpdateProfileWork
import io.nekohasekai.sfa.constant.Bugs
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.HookStatusClient
@@ -43,9 +45,20 @@ class Application : Application() {
HookStatusClient.register(this)
PrivilegeSettingsClient.register(this)
val baseDir = filesDir
baseDir.mkdirs()
val workingDir = getExternalFilesDir(null)
val tempDir = cacheDir
tempDir.mkdirs()
if (workingDir != null) {
workingDir.mkdirs()
CrashReportManager.install(workingDir, baseDir)
OOMReportManager.install(workingDir)
}
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) {
initialize()
initialize(baseDir, workingDir, tempDir)
UpdateProfileWork.reconfigureUpdater()
HookModuleUpdateNotifier.sync(this@Application)
}
@@ -62,24 +75,33 @@ class Application : Application() {
}
}
private fun initialize() {
private fun initialize(baseDir: File, workingDir: File?, tempDir: File) {
val actualWorkingDir = workingDir ?: return
setupLibbox(baseDir, actualWorkingDir, tempDir)
}
fun reloadSetupOptions() {
val baseDir = filesDir
baseDir.mkdirs()
val workingDir = getExternalFilesDir(null) ?: return
workingDir.mkdirs()
val tempDir = cacheDir
tempDir.mkdirs()
Libbox.setup(
SetupOptions().also {
it.basePath = baseDir.path
it.workingPath = workingDir.path
it.tempPath = tempDir.path
it.fixAndroidStack = Bugs.fixAndroidStack
it.logMaxLines = 3000
it.debug = BuildConfig.DEBUG
},
)
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir))
}
private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) {
Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir))
}
private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also {
it.basePath = baseDir.path
it.workingPath = workingDir.path
it.tempPath = tempDir.path
it.fixAndroidStack = Bugs.fixAndroidStack
it.logMaxLines = 3000
it.debug = BuildConfig.DEBUG
it.crashReportSource = "Application"
it.oomKillerEnabled = Settings.oomKillerEnabled
it.oomKillerDisabled = Settings.oomKillerDisabled
it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L
}
companion object {
@@ -417,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl
}
}
override fun triggerNativeCrash() {
Thread {
Thread.sleep(200)
throw RuntimeException("debug native crash")
}.start()
}
override fun writeDebugMessage(message: String?) {
Log.d("sing-box", message!!)
}
@@ -0,0 +1,251 @@
package io.nekohasekai.sfa.bg
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class CrashReport(
val id: String,
val date: Date,
val directory: File,
val isRead: Boolean,
)
data class CrashReportFile(
val kind: Kind,
val displayName: String,
val file: File,
) {
enum class Kind {
METADATA,
GO_LOG,
JVM_LOG,
CONFIG,
}
}
object CrashReportManager {
private const val METADATA_FILE_NAME = "metadata.json"
private const val GO_LOG_FILE_NAME = "go.log"
private const val JVM_LOG_FILE_NAME = "jvm.log"
private const val CONFIG_FILE_NAME = "configuration.json"
private const val READ_MARKER_FILE_NAME = ".read"
private const val CRASH_REPORTS_DIR_NAME = "crash_reports"
private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log"
private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json"
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private lateinit var workingDir: File
private lateinit var baseDir: File
private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
val reports: StateFlow<List<CrashReport>> = _reports
private val _unreadCount = MutableStateFlow(0)
val unreadCount: StateFlow<Int> = _unreadCount
fun install(workingDir: File, baseDir: File) {
this.workingDir = workingDir
this.baseDir = baseDir
archivePendingJvmCrashReport()
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
writePendingJvmCrashReport(thread, throwable)
previous?.uncaughtException(thread, throwable)
}
}
private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) {
try {
val writer = StringWriter()
throwable.printStackTrace(PrintWriter(writer))
File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString())
val metadata = JSONObject().apply {
put("source", "Application")
put("crashedAt", formatTimestampISO8601(Date()))
put("exceptionName", throwable.javaClass.name)
put("exceptionReason", throwable.message ?: "")
put("processName", Application.application.packageName)
put("appVersion", BuildConfig.VERSION_CODE.toString())
put("appMarketingVersion", BuildConfig.VERSION_NAME)
runCatching {
put("coreVersion", Libbox.version())
put("goVersion", Libbox.goVersion())
}
}
File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString())
} catch (_: Throwable) {
}
}
suspend fun refresh() = withContext(Dispatchers.IO) {
val reports = scanCrashReports()
_reports.value = reports
_unreadCount.value = reports.count { !it.isRead }
}
private fun archivePendingJvmCrashReport() {
val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME)
val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME)
val configFile = File(baseDir, CONFIG_FILE_NAME)
if (!crashFile.exists()) return
val content = crashFile.readText().trim()
if (content.isEmpty()) {
crashFile.delete()
metadataFile.delete()
configFile.delete()
return
}
val crashDate = Date(crashFile.lastModified())
val reportDir = nextAvailableReportDir(crashDate)
reportDir.mkdirs()
crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true)
crashFile.delete()
if (metadataFile.exists()) {
metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true)
metadataFile.delete()
}
if (configFile.exists()) {
val configContent = runCatching { configFile.readText() }.getOrNull()?.trim()
if (!configContent.isNullOrEmpty()) {
configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true)
}
configFile.delete()
}
}
private fun scanCrashReports(): List<CrashReport> {
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
if (!crashReportsDir.isDirectory) return emptyList()
val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
return directories.mapNotNull { dir ->
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
CrashReport(
id = dir.name,
date = date,
directory = dir,
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
)
}.sortedByDescending { it.date }
}
fun availableFiles(report: CrashReport): List<CrashReportFile> {
val files = mutableListOf<CrashReportFile>()
val metadataFile = File(report.directory, METADATA_FILE_NAME)
if (metadataFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile))
}
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
if (goLogFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile))
}
val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME)
if (jvmLogFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile))
}
val configFile = File(report.directory, CONFIG_FILE_NAME)
if (configFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile))
}
return files
}
fun loadFileContent(file: CrashReportFile): String {
if (!file.file.exists()) return ""
val content = file.file.readText()
if (file.kind == CrashReportFile.Kind.METADATA) {
return runCatching {
JSONObject(content).toString(2)
}.getOrDefault(content)
}
return content
}
fun markAsRead(report: CrashReport) {
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
val updated = _reports.value.map {
if (it.id == report.id) it.copy(isRead = true) else it
}
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) {
report.directory.deleteRecursively()
val updated = _reports.value.filter { it.id != report.id }
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun deleteAll() = withContext(Dispatchers.IO) {
File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively()
_reports.value = emptyList()
_unreadCount.value = 0
}
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
report.directory.copyRecursively(strippedDir, overwrite = true)
File(strippedDir, READ_MARKER_FILE_NAME).delete()
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
zipFile
}
private fun nextAvailableReportDir(date: Date): File {
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
val baseName = timestampFormat.format(date)
var index = 0
while (true) {
val suffix = if (index == 0) "" else "-$index"
val dir = File(crashReportsDir, baseName + suffix)
if (!dir.exists()) return dir
index++
}
}
private fun parseTimestamp(name: String): Date? {
val components = name.split("-")
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
components.dropLast(1).joinToString("-")
} else {
name
}
return try {
timestampFormat.parse(baseName)
} catch (_: ParseException) {
null
}
}
private fun formatTimestampISO8601(date: Date): String {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return format.format(date)
}
}
@@ -0,0 +1,165 @@
package io.nekohasekai.sfa.bg
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class OOMReport(
val id: String,
val date: Date,
val directory: File,
val isRead: Boolean,
)
data class OOMReportFile(
val kind: Kind,
val displayName: String,
val file: File,
) {
enum class Kind {
METADATA,
CONFIG,
PROFILE,
}
}
object OOMReportManager {
private const val METADATA_FILE_NAME = "metadata.json"
private const val CONFIG_FILE_NAME = "configuration.json"
private const val CMDLINE_FILE_NAME = "cmdline"
private const val READ_MARKER_FILE_NAME = ".read"
private const val OOM_REPORTS_DIR_NAME = "oom_reports"
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private lateinit var workingDir: File
private val _reports = MutableStateFlow<List<OOMReport>>(emptyList())
val reports: StateFlow<List<OOMReport>> = _reports
private val _unreadCount = MutableStateFlow(0)
val unreadCount: StateFlow<Int> = _unreadCount
fun install(workingDir: File) {
this.workingDir = workingDir
}
suspend fun refresh() = withContext(Dispatchers.IO) {
val reports = scanReports()
_reports.value = reports
_unreadCount.value = reports.count { !it.isRead }
}
private fun scanReports(): List<OOMReport> {
val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME)
if (!reportsDir.isDirectory) return emptyList()
val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
return directories.mapNotNull { dir ->
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
OOMReport(
id = dir.name,
date = date,
directory = dir,
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
)
}.sortedByDescending { it.date }
}
fun availableFiles(report: OOMReport): List<OOMReportFile> {
val files = mutableListOf<OOMReportFile>()
val metadataFile = File(report.directory, METADATA_FILE_NAME)
if (metadataFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile))
}
val configFile = File(report.directory, CONFIG_FILE_NAME)
if (configFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile))
}
report.directory.listFiles()?.filter { file ->
file.isFile &&
file.name != METADATA_FILE_NAME &&
file.name != CONFIG_FILE_NAME &&
file.name != CMDLINE_FILE_NAME &&
file.name != READ_MARKER_FILE_NAME
}?.sortedBy { it.name }?.forEach { file ->
files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file))
}
return files
}
fun loadFileContent(file: OOMReportFile): String {
if (!file.file.exists()) return ""
val content = file.file.readText()
if (file.kind == OOMReportFile.Kind.METADATA) {
return runCatching {
JSONObject(content).toString(2)
}.getOrDefault(content)
}
return content
}
fun markAsRead(report: OOMReport) {
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
val updated = _reports.value.map {
if (it.id == report.id) it.copy(isRead = true) else it
}
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) {
report.directory.deleteRecursively()
val updated = _reports.value.filter { it.id != report.id }
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun deleteAll() = withContext(Dispatchers.IO) {
File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively()
_reports.value = emptyList()
_unreadCount.value = 0
}
fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
report.directory.copyRecursively(strippedDir, overwrite = true)
File(strippedDir, READ_MARKER_FILE_NAME).delete()
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
zipFile
}
private fun parseTimestamp(name: String): Date? {
val components = name.split("-")
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
components.dropLast(1).joinToString("-")
} else {
name
}
return try {
timestampFormat.parse(baseName)
} catch (_: ParseException) {
null
}
}
}
@@ -87,6 +87,9 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.compat.WindowSizeClassCompat
@@ -126,6 +129,7 @@ import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -327,6 +331,89 @@ class MainActivity :
// Snackbar state
val snackbarHostState = remember { SnackbarHostState() }
// Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var pendingApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
var activeApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
var applyServiceChangeJob by remember { mutableStateOf<Job?>(null) }
fun mergeApplyServiceChangeMode(
current: UiEvent.ApplyServiceChange.Mode?,
incoming: UiEvent.ApplyServiceChange.Mode,
): UiEvent.ApplyServiceChange.Mode = when {
current == UiEvent.ApplyServiceChange.Mode.Restart ||
incoming == UiEvent.ApplyServiceChange.Mode.Restart -> {
UiEvent.ApplyServiceChange.Mode.Restart
}
else -> incoming
}
fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) {
if (currentServiceStatus != Status.Started) {
return
}
pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode)
val activeMode = activeApplyServiceChangeMode
if (activeMode != null &&
mergeApplyServiceChangeMode(activeMode, mode) != activeMode
) {
snackbarHostState.currentSnackbarData?.dismiss()
}
if (applyServiceChangeJob?.isActive == true) {
return
}
applyServiceChangeJob =
scope.launch {
while (true) {
val modeToShow = pendingApplyServiceChangeMode ?: break
pendingApplyServiceChangeMode = null
activeApplyServiceChangeMode = modeToShow
val (message, actionLabel) =
when (modeToShow) {
UiEvent.ApplyServiceChange.Mode.Reload -> {
getString(R.string.service_reload_required) to
getString(R.string.action_reload)
}
UiEvent.ApplyServiceChange.Mode.Restart -> {
getString(R.string.service_restart_required) to
getString(R.string.action_restart)
}
}
val result =
snackbarHostState.showSnackbar(
message = message,
actionLabel = actionLabel,
duration = androidx.compose.material3.SnackbarDuration.Short,
)
activeApplyServiceChangeMode = null
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
try {
when (modeToShow) {
UiEvent.ApplyServiceChange.Mode.Reload -> {
withContext(Dispatchers.IO) {
Libbox.newStandaloneCommandClient().serviceReload()
}
}
UiEvent.ApplyServiceChange.Mode.Restart -> {
restartServiceForApplyChange()
}
}
} catch (e: Exception) {
errorMessage = e.message ?: e.toString()
showErrorDialog = true
}
}
}
}
}
// Groups Sheet state
var showGroupsSheet by remember { mutableStateOf(false) }
@@ -335,8 +422,6 @@ class MainActivity :
var showConnectionsSheet by remember { mutableStateOf(false) }
// Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
val pendingIntentError = pendingIntentErrorMessage
LaunchedEffect(pendingIntentError) {
if (pendingIntentError != null) {
@@ -616,11 +701,13 @@ class MainActivity :
val dashboardUiState by dashboardViewModel.uiState.collectAsState()
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
val isToolsSubScreen = currentRoute?.startsWith("tools/") == true
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
val isProfileRoute = currentRoute?.startsWith("profile/") == true
val currentRootRoute =
when {
isSettingsSubScreen -> Screen.Settings.route
isToolsSubScreen -> Screen.Tools.route
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
isProfileRoute -> Screen.Dashboard.route
@@ -630,7 +717,7 @@ class MainActivity :
val isGroupsRoute = currentRootRoute == Screen.Groups.route
val isLogRoute = currentRootRoute == Screen.Log.route
val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute
val isSubScreen = isSettingsSubScreen || isToolsSubScreen || isConnectionsDetail || isProfileRoute
// Get LogViewModel instance if we're on the Log screen
val logViewModel: LogViewModel? =
if (isLogRoute) {
@@ -660,6 +747,14 @@ class MainActivity :
null
}
val isToolsRoute = currentRootRoute == Screen.Tools.route
val tailscaleStatusViewModel: TailscaleStatusViewModel? =
if (isToolsRoute) {
viewModel()
} else {
null
}
val showGroupsInNav = dashboardUiState.hasGroups
val showConnectionsInNav =
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
@@ -674,6 +769,7 @@ class MainActivity :
add(Screen.Connections)
}
add(Screen.Log)
add(Screen.Tools)
add(Screen.Settings)
}
@@ -681,6 +777,7 @@ class MainActivity :
buildSet {
add(Screen.Dashboard.route)
add(Screen.Log.route)
add(Screen.Tools.route)
add(Screen.Settings.route)
if (useNavigationRail && showGroupsInNav) {
add(Screen.Groups.route)
@@ -739,24 +836,7 @@ class MainActivity :
}
}
is UiEvent.RestartToTakeEffect -> {
if (currentServiceStatus == Status.Started) {
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
val result =
snackbarHostState.showSnackbar(
message = "Restart to take effect",
actionLabel = "Restart",
duration = androidx.compose.material3.SnackbarDuration.Short,
)
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
withContext(Dispatchers.IO) {
Libbox.newStandaloneCommandClient().serviceReload()
}
}
}
}
}
is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode)
}
}
}
@@ -919,6 +999,17 @@ class MainActivity :
}
}
val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState()
val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState()
val toolsUnreadCount = crashReportUnreadCount + oomReportUnreadCount
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
CrashReportManager.refresh()
OOMReportManager.refresh()
}
}
CompositionLocalProvider(LocalTopBarController provides topBarController) {
if (useNavigationRail) {
Row(modifier = Modifier.fillMaxSize()) {
@@ -936,6 +1027,10 @@ class MainActivity :
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else if (screen == Screen.Tools && toolsUnreadCount > 0) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
@@ -980,6 +1075,10 @@ class MainActivity :
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else if (screen == Screen.Tools && toolsUnreadCount > 0) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
@@ -1192,6 +1291,30 @@ class MainActivity :
showBackgroundLocationDialog = true
}
private suspend fun restartServiceForApplyChange() {
if (currentServiceStatus != Status.Started) {
return
}
BoxService.stop()
while (true) {
when (currentServiceStatus) {
Status.Stopped -> {
startService()
return
}
Status.Starting -> {
return
}
Status.Started, Status.Stopping -> {
delay(100L)
}
}
}
}
override fun onDestroy() {
connection.disconnect()
super.onDestroy()
@@ -0,0 +1,16 @@
package io.nekohasekai.sfa.compose.base
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.nekohasekai.sfa.constant.Status
@Composable
fun rememberApplyServiceChangeNotifier(
serviceStatus: Status,
): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) {
{ mode ->
if (serviceStatus == Status.Started) {
GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode))
}
}
}
@@ -19,7 +19,12 @@ sealed class UiEvent {
object RequestReconnectService : UiEvent()
object RestartToTakeEffect : UiEvent()
data class ApplyServiceChange(val mode: Mode) : UiEvent() {
enum class Mode {
Reload,
Restart,
}
}
}
/**
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.ui.graphics.vector.ImageVector
import io.nekohasekai.sfa.R
@@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I
icon = Icons.Default.SwapVert,
)
object Tools : Screen(
route = "tools",
titleRes = R.string.title_tools,
icon = Icons.Default.Terminal,
)
object Settings : Screen(
route = "settings",
titleRes = R.string.title_settings,
@@ -46,5 +53,6 @@ val bottomNavigationScreens =
listOf(
Screen.Dashboard,
Screen.Log,
Screen.Tools,
Screen.Settings,
)
@@ -33,6 +33,15 @@ import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen
import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen
import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen
import io.nekohasekai.sfa.constant.Status
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
@@ -210,6 +219,111 @@ fun SFANavHost(
}
}
composable(Screen.Tools.route) {
ToolsScreen(navController = navController)
}
// Tools subscreens with slide animations
composable(
route = "tools/crash_report",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
CrashReportListScreen(navController = navController)
}
composable(
route = "tools/crash_report/{reportId}",
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
CrashReportDetailScreen(navController = navController, reportId = reportId)
}
composable(
route = "tools/crash_report/{reportId}/metadata",
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
CrashReportMetadataScreen(navController = navController, reportId = reportId)
}
composable(
route = "tools/crash_report/{reportId}/file/{fileKind}",
arguments = listOf(
navArgument("reportId") { type = NavType.StringType },
navArgument("fileKind") { type = NavType.StringType },
),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable
CrashReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind)
}
composable(
route = "tools/oom_report",
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
OOMReportListScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
route = "tools/oom_report/{reportId}",
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
OOMReportDetailScreen(navController = navController, reportId = reportId)
}
composable(
route = "tools/oom_report/{reportId}/metadata",
arguments = listOf(navArgument("reportId") { type = NavType.StringType }),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
OOMReportMetadataScreen(navController = navController, reportId = reportId)
}
composable(
route = "tools/oom_report/{reportId}/file/{fileKind}",
arguments = listOf(
navArgument("reportId") { type = NavType.StringType },
navArgument("fileKind") { type = NavType.StringType },
),
enterTransition = slideInFromRight,
exitTransition = slideOutToLeft,
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) { backStackEntry ->
val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable
val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable
OOMReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind)
}
composable(Screen.Settings.route) {
SettingsScreen(navController = navController)
}
@@ -222,7 +336,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
AppSettingsScreen(navController = navController)
AppSettingsScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
@@ -252,7 +366,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
ServiceSettingsScreen(navController = navController)
ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
@@ -262,7 +376,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
ProfileOverrideScreen(navController = navController)
ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus)
}
composable(
@@ -272,7 +386,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
PerAppProxyScreen(onBack = { navController.navigateUp() })
PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
}
composable(
@@ -292,7 +406,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight,
) {
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() })
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
}
composable(
@@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.shared.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
@@ -95,10 +98,14 @@ private enum class RiskCategory {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
fun PrivilegeSettingsManageScreen(
onBack: () -> Unit,
serviceStatus: Status = Status.Stopped,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var sortMode by remember { mutableStateOf(SortMode.NAME) }
var sortReverse by remember { mutableStateOf(false) }
@@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
}
if (failure != null) {
syncErrorMessage = failure.message ?: failure.toString()
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
@@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.shared.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.vendor.PackageQueryManager
@@ -106,10 +109,14 @@ private sealed class ScanResult {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PerAppProxyScreen(onBack: () -> Unit) {
fun PerAppProxyScreen(
onBack: () -> Unit,
serviceStatus: Status = Status.Stopped,
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) }
var sortMode by remember { mutableStateOf(SortMode.NAME) }
@@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
fun saveSelectedApplications(newUids: Set<Int>) {
coroutineScope.launch {
Settings.perAppProxyList = buildPackageList(newUids)
withContext(Dispatchers.IO) {
Settings.perAppProxyList = buildPackageList(newUids)
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
@@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
onModeChange = { mode ->
proxyMode = mode
coroutineScope.launch {
Settings.perAppProxyMode = mode
withContext(Dispatchers.IO) {
Settings.perAppProxyMode = mode
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
},
onSortModeChange = { mode ->
@@ -88,8 +88,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
@@ -110,7 +113,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)) },
@@ -156,6 +162,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) }
@@ -680,6 +687,9 @@ fun AppSettingsScreen(navController: NavController) {
dynamicNotification = checked
scope.launch(Dispatchers.IO) {
Settings.dynamicNotification = checked
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
)
@@ -62,9 +62,9 @@ import androidx.core.content.FileProvider
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.SelectableMessageDialog
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
@@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
val context = LocalContext.current
val scope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
val systemHookStatus by HookStatusClient.status.collectAsState()
var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) }
@@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true
} else if (serviceStatus == Status.Started) {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
},
@@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true
} else if (checked && serviceStatus == Status.Started) {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
},
@@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true
} else if (serviceStatus == Status.Started) {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
},
@@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.RootClient
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.Dispatchers
@@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileOverrideScreen(navController: NavController) {
fun ProfileOverrideScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.profile_override)) },
@@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) {
var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) }
var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) }
var isScanning by remember { mutableStateOf(false) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
fun scanAndSaveManagedList() {
fun scanAndSaveManagedList(shouldNotify: Boolean = false) {
isScanning = true
scope.launch {
val chinaApps = PerAppProxyScanner.scanAllChinaApps()
@@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyManagedList = chinaApps
}
isScanning = false
if (shouldNotify) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
@@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = true
}
if (managedModeEnabled) {
scanAndSaveManagedList()
scanAndSaveManagedList(shouldNotify = true)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
@@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) {
withContext(Dispatchers.IO) {
Settings.autoRedirect = true
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} else {
Toast.makeText(
context,
@@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) {
autoRedirect = false
scope.launch(Dispatchers.IO) {
Settings.autoRedirect = false
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
},
@@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) {
perAppProxyEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.perAppProxyEnabled = checked
if (!checked || !managedModeEnabled) {
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
if (checked && managedModeEnabled) {
scanAndSaveManagedList()
scanAndSaveManagedList(shouldNotify = true)
}
}
},
@@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) {
scope.launch(Dispatchers.IO) {
Settings.perAppProxyManagedMode = true
}
scanAndSaveManagedList()
scanAndSaveManagedList(shouldNotify = true)
} else {
managedModeEnabled = false
scope.launch(Dispatchers.IO) {
Settings.perAppProxyManagedMode = false
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
},
@@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) {
perAppProxyEnabled = true
scope.launch(Dispatchers.IO) {
Settings.perAppProxyEnabled = true
if (!managedModeEnabled) {
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
}
if (managedModeEnabled) {
scanAndSaveManagedList()
scanAndSaveManagedList(shouldNotify = true)
}
},
) {
@@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = true
}
if (managedModeEnabled) {
scanAndSaveManagedList()
scanAndSaveManagedList(shouldNotify = true)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
} else {
showRootDialog = false
@@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = false
}
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
showModeDialog = false
},
colors = ListItemDefaults.colors(
@@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) {
scope.launch(Dispatchers.IO) {
Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
showModeDialog = false
},
colors = ListItemDefaults.colors(
@@ -57,15 +57,23 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.launchCustomTab
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) {
fun ServiceSettingsScreen(
navController: NavController,
serviceConnection: ServiceConnection? = null,
serviceStatus: Status = Status.Stopped,
) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.service)) },
@@ -84,6 +92,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
val scope = rememberCoroutineScope()
var isBatteryOptimizationIgnored by remember { mutableStateOf(false) }
var allowBypass by remember { mutableStateOf(Settings.allowBypass) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
val requestBatteryOptimizationLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
@@ -255,6 +264,9 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
allowBypass = checked
scope.launch(Dispatchers.IO) {
Settings.allowBypass = checked
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
},
)
@@ -0,0 +1,459 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.CrashReport
import io.nekohasekai.sfa.bg.CrashReportFile
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportDetailScreen(navController: NavController, reportId: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<CrashReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
files = CrashReportManager.availableFiles(report)
}
CrashReportManager.markAsRead(report)
}
isLoading = false
}
val title = if (report != null) {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
} else {
reportId
}
val hasConfig = report != null && CrashReportManager.hasConfigFile(report)
fun shareReport(includeConfig: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
}
OverrideTopBar {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
}
IconButton(onClick = {
scope.launch {
if (report != null) {
CrashReportManager.delete(report)
}
navController.navigateUp()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (files.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
stringResource(R.string.report_section_files),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
files.forEachIndexed { index, file ->
val shape = when {
files.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == files.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
val icon = when (file.kind) {
CrashReportFile.Kind.METADATA -> Icons.Default.DataObject
CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal
CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport
CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings
}
ListItem(
headlineContent = {
Text(
file.displayName,
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.clickable {
if (file.kind == CrashReportFile.Kind.METADATA) {
navController.navigate("tools/crash_report/$reportId/metadata")
} else {
navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportMetadataScreen(navController: NavController, reportId: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
entries = loadMetadataEntries(report)
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.report_metadata)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
entries.forEachIndexed { index, (key, value) ->
val shape = when {
entries.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
)
index == entries.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
key,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(value)
},
modifier = Modifier.clip(shape),
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
}
}
}
}
private fun loadMetadataEntries(report: CrashReport): List<Pair<String, String>> {
val metadataFile = CrashReportManager.availableFiles(report)
.find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList()
val content = metadataFile.file.readText()
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
return json.keys().asSequence()
.mapNotNull { key ->
val value = json.optString(key, "")
if (value.isNotBlank()) key to value else null
}
.toList()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var content by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(true) }
val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull()
val displayName = when (kind) {
CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log)
CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log)
CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration)
else -> fileKind
}
LaunchedEffect(report, kind) {
if (report != null && kind != null) {
withContext(Dispatchers.IO) {
val file = CrashReportManager.availableFiles(report).find { it.kind == kind }
content = if (file != null) CrashReportManager.loadFileContent(file) else ""
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(displayName) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (content.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Text(
text = content,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
}
}
}
@@ -0,0 +1,263 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.FlashOn
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportListScreen(navController: NavController) {
val reports by CrashReportManager.reports.collectAsState()
var isLoading by remember { mutableStateOf(true) }
var menuExpanded by remember { mutableStateOf(false) }
var crashTriggerExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
CrashReportManager.refresh()
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.crash_report)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (reports.isNotEmpty() || BuildConfig.DEBUG) {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = {
menuExpanded = false
crashTriggerExpanded = false
},
) {
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("Crash Trigger") },
leadingIcon = {
Icon(
Icons.Outlined.FlashOn,
contentDescription = null,
)
},
onClick = { crashTriggerExpanded = !crashTriggerExpanded },
)
if (crashTriggerExpanded) {
DropdownMenuItem(
text = {
Text(
"Go Crash",
modifier = Modifier.padding(start = 16.dp),
)
},
onClick = {
menuExpanded = false
crashTriggerExpanded = false
Libbox.triggerGoPanic()
},
)
DropdownMenuItem(
text = {
Text(
"Native Crash",
modifier = Modifier.padding(start = 16.dp),
)
},
onClick = {
menuExpanded = false
crashTriggerExpanded = false
Thread {
Thread.sleep(200)
throw RuntimeException("debug native crash")
}.start()
},
)
}
}
if (reports.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.report_delete_all),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Outlined.DeleteSweep,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuExpanded = false
scope.launch {
CrashReportManager.deleteAll()
}
},
)
}
}
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
stringResource(R.string.report_section_reports),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
if (reports.isEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
} else {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
reports.forEachIndexed { index, report ->
val shape = when {
reports.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == reports.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
formatDate(report.date),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
)
},
leadingContent = if (!report.isRead) {
{
Badge(
containerColor = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate("tools/crash_report/${report.id}")
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
Text(
stringResource(R.string.crash_report_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
}
}
}
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
@@ -0,0 +1,451 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.OOMReport
import io.nekohasekai.sfa.bg.OOMReportFile
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportDetailScreen(navController: NavController, reportId: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<OOMReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
files = OOMReportManager.availableFiles(report)
}
OOMReportManager.markAsRead(report)
}
isLoading = false
}
val title = if (report != null) {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
} else {
reportId
}
val hasConfig = report != null && OOMReportManager.hasConfigFile(report)
fun shareReport(includeConfig: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
}
OverrideTopBar {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
}
IconButton(onClick = {
scope.launch {
if (report != null) {
OOMReportManager.delete(report)
}
navController.navigateUp()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (files.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
stringResource(R.string.report_section_files),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
files.forEachIndexed { index, file ->
val shape = when {
files.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == files.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
val icon = when (file.kind) {
OOMReportFile.Kind.METADATA -> Icons.Default.DataObject
OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings
OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal
}
ListItem(
headlineContent = {
Text(
file.displayName,
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.then(
if (file.kind != OOMReportFile.Kind.PROFILE) {
Modifier.clickable {
if (file.kind == OOMReportFile.Kind.METADATA) {
navController.navigate("tools/oom_report/$reportId/metadata")
} else {
navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}")
}
}
} else {
Modifier
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportMetadataScreen(navController: NavController, reportId: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
entries = loadOOMMetadataEntries(report)
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.report_metadata)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
entries.forEachIndexed { index, (key, value) ->
val shape = when {
entries.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == entries.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(key, style = MaterialTheme.typography.bodyLarge)
},
supportingContent = { Text(value) },
modifier = Modifier.clip(shape),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
}
private fun loadOOMMetadataEntries(report: OOMReport): List<Pair<String, String>> {
val metadataFile = OOMReportManager.availableFiles(report)
.find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList()
val content = metadataFile.file.readText()
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
return json.keys().asSequence()
.mapNotNull { key ->
val value = json.optString(key, "")
if (value.isNotBlank()) key to value else null
}
.toList()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var content by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf(fileKind) }
var isLoading by remember { mutableStateOf(true) }
val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull()
LaunchedEffect(report, kind) {
if (report != null && kind != null) {
withContext(Dispatchers.IO) {
val file = OOMReportManager.availableFiles(report).find { it.kind == kind }
if (file != null) {
displayName = file.displayName
content = OOMReportManager.loadFileContent(file)
}
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(displayName) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (content.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Text(
text = content,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
}
}
}
@@ -0,0 +1,416 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportListScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
) {
val reports by OOMReportManager.reports.collectAsState()
var isLoading by remember { mutableStateOf(true) }
var menuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var errorMessage by remember { mutableStateOf<String?>(null) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) }
var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) }
var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) }
var showMemoryLimitDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
OOMReportManager.refresh()
val storedLimit = Settings.oomMemoryLimitMB
if (!memoryLimitOptions.contains(storedLimit)) {
oomMemoryLimitMB = memoryLimitOptions.first()
Settings.oomMemoryLimitMB = oomMemoryLimitMB
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.oom_report)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.oom_report_fetch)) },
leadingIcon = {
Icon(Icons.Outlined.Memory, contentDescription = null)
},
onClick = {
menuExpanded = false
if (serviceStatus != Status.Started) {
errorMessage =
Application.application.getString(R.string.service_not_started)
} else {
scope.launch {
val failure =
withContext(Dispatchers.IO) {
runCatching {
Libbox.newStandaloneCommandClient().triggerOOMReport()
}.exceptionOrNull()
}
if (failure != null) {
errorMessage = failure.message ?: failure.toString()
} else {
delay(1000)
withContext(Dispatchers.IO) {
OOMReportManager.refresh()
}
}
}
}
},
)
if (reports.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.report_delete_all),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Outlined.DeleteSweep,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuExpanded = false
scope.launch { OOMReportManager.deleteAll() }
},
)
}
}
},
)
}
Box(modifier = Modifier.fillMaxSize()) {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// Reports section
Text(
stringResource(R.string.report_section_reports),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
if (reports.isEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
} else {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
reports.forEachIndexed { index, report ->
val shape = when {
reports.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == reports.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
formatDate(report.date),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
)
},
leadingContent = if (!report.isRead) {
{
Badge(
containerColor = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate("tools/oom_report/${report.id}")
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
Text(
stringResource(R.string.oom_report_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
// Settings section
Text(
stringResource(R.string.title_settings),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_enable_memory_limit),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(stringResource(R.string.oom_report_enable_memory_limit_description))
},
trailingContent = {
Switch(
checked = oomKillerEnabled,
onCheckedChange = { checked ->
oomKillerEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.oomKillerEnabled = checked
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
AnimatedVisibility(visible = oomKillerEnabled) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_memory_limit),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L))
},
modifier = Modifier.clickable { showMemoryLimitDialog = true },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_kill_connections),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(stringResource(R.string.oom_report_kill_connections_description))
},
trailingContent = {
Switch(
checked = oomKillerKillConnections,
onCheckedChange = { checked ->
oomKillerKillConnections = checked
scope.launch(Dispatchers.IO) {
Settings.oomKillerDisabled = !checked
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
}
errorMessage?.let { message ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = { errorMessage = null }) {
Text(stringResource(android.R.string.ok))
}
},
text = { Text(message) },
)
}
if (showMemoryLimitDialog) {
AlertDialog(
onDismissRequest = { showMemoryLimitDialog = false },
title = { Text(stringResource(R.string.oom_report_memory_limit)) },
text = {
Column {
memoryLimitOptions.forEach { value ->
ListItem(
headlineContent = {
Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L))
},
leadingContent = {
RadioButton(
selected = value == oomMemoryLimitMB,
onClick = null,
)
},
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable {
oomMemoryLimitMB = value
showMemoryLimitDialog = false
scope.launch(Dispatchers.IO) {
Settings.oomMemoryLimitMB = value
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
},
confirmButton = {},
)
}
}
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
@@ -0,0 +1,127 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolsScreen(navController: NavController) {
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.title_tools)) },
)
}
val crashUnreadCount by CrashReportManager.unreadCount.collectAsState()
val oomUnreadCount by OOMReportManager.unreadCount.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
text = stringResource(R.string.title_debug),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.crash_report),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.BugReport,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (crashUnreadCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("$crashUnreadCount")
}
}
},
modifier = Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.clickable { navController.navigate("tools/crash_report") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Memory,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (oomUnreadCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("$oomUnreadCount")
}
}
},
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { navController.navigate("tools/oom_report") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
@@ -31,6 +31,11 @@ object SettingsKey {
const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled"
const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix"
// OOM killer
const val OOM_KILLER_ENABLED = "oom_killer_enabled"
const val OOM_KILLER_DISABLED = "oom_killer_disabled"
const val OOM_MEMORY_LIMIT_MB = "oom_memory_limit_mb"
// dashboard
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
@@ -106,6 +106,10 @@ object Settings {
) { false }
var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" }
var oomKillerEnabled by dataStore.boolean(SettingsKey.OOM_KILLER_ENABLED) { false }
var oomKillerDisabled by dataStore.boolean(SettingsKey.OOM_KILLER_DISABLED) { true }
var oomMemoryLimitMB by dataStore.int(SettingsKey.OOM_MEMORY_LIMIT_MB) { 50 }
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }