diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a7394e5f0..b32d17aad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -323,7 +323,7 @@ dependencies { // Xposed API for self-hooking VPN hide module compileOnly("de.robv.android.xposed:api:82") - compileOnly(project(":libxposed-api")) + compileOnly("io.github.libxposed:api:101.0.1") } val playCredentialsJSON = rootProject.file("service-account-credentials.json") diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl new file mode 100644 index 000000000..a2ed3cf53 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.bg; + +import io.nekohasekai.sfa.bg.ParceledListSlice; + +interface INeighborTableCallback { + oneway void onNeighborTableUpdated(in ParceledListSlice entries); +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl index fc5816115..382c19262 100644 --- a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -1,6 +1,7 @@ package io.nekohasekai.sfa.bg; import android.os.ParcelFileDescriptor; +import io.nekohasekai.sfa.bg.INeighborTableCallback; import io.nekohasekai.sfa.bg.ParceledListSlice; interface IRootService { @@ -11,4 +12,8 @@ interface IRootService { void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; String exportDebugInfo(String outputPath) = 3; + + void registerNeighborTableCallback(in INeighborTableCallback callback) = 4; + + oneway void unregisterNeighborTableCallback(in INeighborTableCallback callback) = 5; } diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl new file mode 100644 index 000000000..8c3cf8147 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable NeighborEntry; diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 02b2467b9..b5f490a32 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -9,13 +9,16 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.PowerManager +import android.util.Log 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 @@ -39,13 +42,28 @@ class Application : Application() { AppLifecycleObserver.register(this) // Seq.setContext(this) - Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + runCatching { + Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + }.onFailure { + Log.d("Application", "set locale: ${it.message}") + } 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 +80,33 @@ class Application : Application() { } } - private fun initialize() { + private fun initialize(baseDir: File, workingDir: File?, tempDir: File) { + val actualWorkingDir = workingDir ?: return + setupLibbox(baseDir, actualWorkingDir, tempDir) + } + + fun reloadSetupOptions() { val baseDir = filesDir - baseDir.mkdirs() val workingDir = getExternalFilesDir(null) ?: return - workingDir.mkdirs() val tempDir = cacheDir - tempDir.mkdirs() - Libbox.setup( - SetupOptions().also { - it.basePath = baseDir.path - it.workingPath = workingDir.path - it.tempPath = tempDir.path - it.fixAndroidStack = Bugs.fixAndroidStack - it.logMaxLines = 3000 - it.debug = BuildConfig.DEBUG - }, - ) - Libbox.redirectStderr(File(workingDir, "stderr.log").path) + Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) { + Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also { + it.basePath = baseDir.path + it.workingPath = workingDir.path + it.tempPath = tempDir.path + it.fixAndroidStack = Bugs.fixAndroidStack + it.logMaxLines = 3000 + it.debug = BuildConfig.DEBUG + it.crashReportSource = "Application" + it.oomKillerEnabled = Settings.oomKillerEnabled + it.oomKillerDisabled = Settings.oomKillerDisabled + it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L } companion object { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt index 013406c99..6331123ee 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -21,6 +21,11 @@ class BootReceiver : BroadcastReceiver() { } GlobalScope.launch(Dispatchers.IO) { if (Settings.startedByUser) { + CrashReportManager.refresh() + if (CrashReportManager.unreadCount.value > 0) { + Settings.startedByUser = false + return@launch + } withContext(Dispatchers.Main) { BoxService.start() } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index a35486680..da211c822 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -417,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } + override fun triggerNativeCrash() { + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + } + override fun writeDebugMessage(message: String?) { Log.d("sing-box", message!!) } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt new file mode 100644 index 000000000..cb2a27be1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -0,0 +1,251 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class CrashReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class CrashReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + GO_LOG, + JVM_LOG, + CONFIG, + } +} + +object CrashReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val GO_LOG_FILE_NAME = "go.log" + private const val JVM_LOG_FILE_NAME = "jvm.log" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val READ_MARKER_FILE_NAME = ".read" + private const val CRASH_REPORTS_DIR_NAME = "crash_reports" + private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log" + private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + private lateinit var baseDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File, baseDir: File) { + this.workingDir = workingDir + this.baseDir = baseDir + archivePendingJvmCrashReport() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + writePendingJvmCrashReport(thread, throwable) + previous?.uncaughtException(thread, throwable) + } + } + + private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) { + try { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString()) + val metadata = JSONObject().apply { + put("source", "Application") + put("crashedAt", formatTimestampISO8601(Date())) + put("exceptionName", throwable.javaClass.name) + put("exceptionReason", throwable.message ?: "") + put("processName", Application.application.packageName) + put("appVersion", BuildConfig.VERSION_CODE.toString()) + put("appMarketingVersion", BuildConfig.VERSION_NAME) + runCatching { + put("coreVersion", Libbox.version()) + put("goVersion", Libbox.goVersion()) + } + } + File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString()) + } catch (_: Throwable) { + } + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanCrashReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun archivePendingJvmCrashReport() { + val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME) + val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME) + val configFile = File(baseDir, CONFIG_FILE_NAME) + if (!crashFile.exists()) return + val content = crashFile.readText().trim() + if (content.isEmpty()) { + crashFile.delete() + metadataFile.delete() + configFile.delete() + return + } + val crashDate = Date(crashFile.lastModified()) + val reportDir = nextAvailableReportDir(crashDate) + reportDir.mkdirs() + crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true) + crashFile.delete() + if (metadataFile.exists()) { + metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true) + metadataFile.delete() + } + if (configFile.exists()) { + val configContent = runCatching { configFile.readText() }.getOrNull()?.trim() + if (!configContent.isNullOrEmpty()) { + configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true) + } + configFile.delete() + } + } + + private fun scanCrashReports(): List { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + if (!crashReportsDir.isDirectory) return emptyList() + val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + CrashReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: CrashReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val goLogFile = File(report.directory, GO_LOG_FILE_NAME) + if (goLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile)) + } + val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME) + if (jvmLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile)) + } + return files + } + + fun loadFileContent(file: CrashReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == CrashReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: CrashReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun nextAvailableReportDir(date: Date): File { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + val baseName = timestampFormat.format(date) + var index = 0 + while (true) { + val suffix = if (index == 0) "" else "-$index" + val dir = File(crashReportsDir, baseName + suffix) + if (!dir.exists()) return dir + index++ + } + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } + + private fun formatTimestampISO8601(date: Date): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return format.format(date) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java new file mode 100644 index 000000000..97c97ad23 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.bg; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; + +public class NeighborEntry implements Parcelable { + @NonNull public final String address; + @NonNull public final String macAddress; + @NonNull public final String hostname; + + public NeighborEntry( + @NonNull String address, @NonNull String macAddress, @NonNull String hostname) { + this.address = address; + this.macAddress = macAddress; + this.hostname = hostname; + } + + protected NeighborEntry(Parcel in) { + address = in.readString(); + macAddress = in.readString(); + hostname = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(address); + dest.writeString(macAddress); + dest.writeString(hostname); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public NeighborEntry createFromParcel(Parcel in) { + return new NeighborEntry(in); + } + + @Override + public NeighborEntry[] newArray(int size) { + return new NeighborEntry[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt new file mode 100644 index 000000000..183b19e45 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -0,0 +1,165 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class OOMReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class OOMReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + CONFIG, + PROFILE, + } +} + +object OOMReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val CMDLINE_FILE_NAME = "cmdline" + private const val READ_MARKER_FILE_NAME = ".read" + private const val OOM_REPORTS_DIR_NAME = "oom_reports" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File) { + this.workingDir = workingDir + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun scanReports(): List { + val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME) + if (!reportsDir.isDirectory) return emptyList() + val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + OOMReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: OOMReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile)) + } + report.directory.listFiles()?.filter { file -> + file.isFile && + file.name != METADATA_FILE_NAME && + file.name != CONFIG_FILE_NAME && + file.name != CMDLINE_FILE_NAME && + file.name != READ_MARKER_FILE_NAME + }?.sortedBy { it.name }?.forEach { file -> + files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file)) + } + return files + } + + fun loadFileContent(file: OOMReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == OOMReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: OOMReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 7a0be3ce0..78b388803 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -11,12 +11,16 @@ import io.nekohasekai.libbox.ConnectionOwner import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LocalDNSTransport +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.WIFIState import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import java.net.Inet6Address import java.net.InetSocketAddress import java.net.InterfaceAddress @@ -24,8 +28,11 @@ import java.net.NetworkInterface import java.security.KeyStore import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import io.nekohasekai.libbox.NeighborEntry as LibboxNeighborEntry import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface +private var neighborCallback: INeighborTableCallback.Stub? = null + interface PlatformInterfaceWrapper : PlatformInterface { override fun usePlatformAutoDetectInterfaceControl(): Boolean = true @@ -172,6 +179,49 @@ interface PlatformInterfaceWrapper : PlatformInterface { return StringArray(certificates.iterator()) } + override fun startNeighborMonitor(listener: NeighborUpdateListener?) { + if (listener == null) return + val callback = object : INeighborTableCallback.Stub() { + override fun onNeighborTableUpdated(entries: ParceledListSlice<*>?) { + if (entries == null) return + @Suppress("UNCHECKED_CAST") + val list = entries.list as List + listener.updateNeighborTable( + NeighborEntryArray( + list.map { entry -> + LibboxNeighborEntry().apply { + address = entry.address + macAddress = entry.macAddress + hostname = entry.hostname + } + }.iterator(), + ), + ) + } + } + neighborCallback = callback + runBlocking(Dispatchers.IO) { + RootClient.registerNeighborTableCallback(callback) + } + } + + override fun registerMyInterface(name: String?) { + } + + override fun closeNeighborMonitor(listener: NeighborUpdateListener?) { + val callback = neighborCallback ?: return + neighborCallback = null + runBlocking(Dispatchers.IO) { + RootClient.unregisterNeighborTableCallback(callback) + } + } + + private class NeighborEntryArray(private val iterator: Iterator) : NeighborEntryIterator { + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): LibboxNeighborEntry = iterator.next() + } + private class InterfaceArray(private val iterator: Iterator) : NetworkInterfaceIterator { override fun hasNext(): Boolean = iterator.hasNext() diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index a7b24b994..3cdc8f32b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -133,4 +133,21 @@ object RootClient { throw e.rethrowFromSystemServer() } } + + suspend fun registerNeighborTableCallback(callback: INeighborTableCallback) { + val svc = bindService() + try { + svc.registerNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() + } + } + + suspend fun unregisterNeighborTableCallback(callback: INeighborTableCallback) { + try { + service?.unregisterNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() + } + } } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt index 352d15963..13b0bccb0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -2,15 +2,36 @@ package io.nekohasekai.sfa.bg import android.content.Intent import android.content.pm.PackageInfo +import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor +import android.os.RemoteCallbackList +import android.util.Log import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborSubscription +import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils import java.io.IOException +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors class RootServer : RootService() { + private val neighborCallbacks = RemoteCallbackList() + private var neighborSubscription: NeighborSubscription? = null + + private val hostnameByMAC = ConcurrentHashMap() + + @Volatile + private var lastNeighborEntries: List>? = null + + private var tetheringCallback: Any? = null + private var tetheringManager: Any? = null + private val binder = object : IRootService.Stub() { override fun destroy() { stopSelf() @@ -31,7 +52,174 @@ class RootServer : RootService() { outputPath!!, BuildConfig.APPLICATION_ID, ) + + override fun registerNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.register(callback) + synchronized(neighborCallbacks) { + if (neighborSubscription == null) { + try { + neighborSubscription = + Libbox.subscribeNeighborTable(object : NeighborUpdateListener { + override fun updateNeighborTable(entries: NeighborEntryIterator?) { + if (entries == null) return + val rawList = mutableListOf>() + while (entries.hasNext()) { + val entry = entries.next() + rawList.add(entry.address to entry.macAddress) + } + lastNeighborEntries = rawList + broadcastEnrichedEntries(rawList) + } + }) + } catch (e: Exception) { + Log.e("RootServer", "subscribeNeighborTable failed", e) + } + startTetheringMonitor() + } + } + } + + override fun unregisterNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.unregister(callback) + synchronized(neighborCallbacks) { + if (neighborCallbacks.registeredCallbackCount == 0) { + neighborSubscription?.close() + neighborSubscription = null + stopTetheringMonitor() + } + } + } + } + + private fun broadcastEnrichedEntries(rawList: List>) { + val list = rawList.map { (address, mac) -> + NeighborEntry(address, mac, hostnameByMAC[mac.uppercase()] ?: "") + } + Log.d("RootServer", "neighborTable updated: ${list.size} entries") + val slice = ParceledListSlice(list) + val count = neighborCallbacks.beginBroadcast() + try { + repeat(count) { i -> + try { + neighborCallbacks.getBroadcastItem(i).onNeighborTableUpdated(slice) + } catch (_: Exception) { + } + } + } finally { + neighborCallbacks.finishBroadcast() + } + } + + // TetheringManager reflection (API 30+) + + private val classTetheredClient by lazy { + Class.forName("android.net.TetheredClient") + } + private val getMacAddress by lazy { + classTetheredClient.getDeclaredMethod("getMacAddress") + } + private val getAddresses by lazy { + classTetheredClient.getDeclaredMethod("getAddresses") + } + private val classAddressInfo by lazy { + Class.forName("android.net.TetheredClient\$AddressInfo") + } + private val getHostname by lazy { + classAddressInfo.getDeclaredMethod("getHostname") + } + + private fun startTetheringMonitor() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + try { + val manager = getSystemService("tethering") ?: return + tetheringManager = manager + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val registerMethod = manager.javaClass.getMethod( + "registerTetheringEventCallback", + java.util.concurrent.Executor::class.java, + callbackClass, + ) + val proxy = Proxy.newProxyInstance( + callbackClass.classLoader, + arrayOf(callbackClass), + ) { proxyObject, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(proxyObject) + "equals" -> proxyObject === args?.get(0) + "toString" -> + proxyObject.javaClass.name + "@" + + Integer.toHexString(System.identityHashCode(proxyObject)) + "onClientsChanged" -> { + if (args != null) { + @Suppress("UNCHECKED_CAST") + handleClientsChanged(args[0] as Collection<*>) + } + null + } + else -> null + } + } + tetheringCallback = proxy + registerMethod.invoke(manager, Executors.newSingleThreadExecutor(), proxy) + Log.d("RootServer", "TetheringManager monitor started") + } catch (e: Exception) { + Log.e("RootServer", "startTetheringMonitor failed", e) + } + } + + private fun stopTetheringMonitor() { + val manager = tetheringManager ?: return + val callback = tetheringCallback ?: return + try { + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val unregisterMethod = manager.javaClass.getMethod( + "unregisterTetheringEventCallback", + callbackClass, + ) + unregisterMethod.invoke(manager, callback) + } catch (e: Exception) { + Log.e("RootServer", "stopTetheringMonitor failed", e) + } + tetheringCallback = null + tetheringManager = null + hostnameByMAC.clear() + } + + private fun handleClientsChanged(clients: Collection<*>) { + hostnameByMAC.clear() + for (client in clients) { + if (client == null) continue + try { + val mac = getMacAddress.invoke(client).toString().uppercase() + + @Suppress("UNCHECKED_CAST") + val addresses = getAddresses.invoke(client) as List<*> + for (info in addresses) { + if (info == null) continue + val hostname = getHostname.invoke(info) as? String + if (!hostname.isNullOrEmpty()) { + hostnameByMAC[mac] = hostname + } + } + } catch (e: Exception) { + Log.e("RootServer", "handleClientsChanged reflection error", e) + } + } + Log.d("RootServer", "tethered clients updated: ${hostnameByMAC.size} hostnames") + lastNeighborEntries?.let { broadcastEnrichedEntries(it) } } override fun onBind(intent: Intent): IBinder = binder + + override fun onDestroy() { + stopTetheringMonitor() + neighborSubscription?.close() + neighborSubscription = null + neighborCallbacks.kill() + super.onDestroy() + } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 56e8639c3..80f12bfad 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -87,6 +87,9 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compat.WindowSizeClassCompat @@ -111,6 +114,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController @@ -126,6 +130,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 +332,89 @@ class MainActivity : // Snackbar state val snackbarHostState = remember { SnackbarHostState() } + // Error dialog state for UiEvent.ShowError + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var pendingApplyServiceChangeMode by remember { mutableStateOf(null) } + var activeApplyServiceChangeMode by remember { mutableStateOf(null) } + var applyServiceChangeJob by remember { mutableStateOf(null) } + + fun mergeApplyServiceChangeMode( + current: UiEvent.ApplyServiceChange.Mode?, + incoming: UiEvent.ApplyServiceChange.Mode, + ): UiEvent.ApplyServiceChange.Mode = when { + current == UiEvent.ApplyServiceChange.Mode.Restart || + incoming == UiEvent.ApplyServiceChange.Mode.Restart -> { + UiEvent.ApplyServiceChange.Mode.Restart + } + + else -> incoming + } + + fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) { + if (currentServiceStatus != Status.Started) { + return + } + + pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode) + + val activeMode = activeApplyServiceChangeMode + if (activeMode != null && + mergeApplyServiceChangeMode(activeMode, mode) != activeMode + ) { + snackbarHostState.currentSnackbarData?.dismiss() + } + + if (applyServiceChangeJob?.isActive == true) { + return + } + + applyServiceChangeJob = + scope.launch { + while (true) { + val modeToShow = pendingApplyServiceChangeMode ?: break + pendingApplyServiceChangeMode = null + activeApplyServiceChangeMode = modeToShow + val (message, actionLabel) = + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + getString(R.string.service_reload_required) to + getString(R.string.action_reload) + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + getString(R.string.service_restart_required) to + getString(R.string.action_restart) + } + } + val result = + snackbarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = androidx.compose.material3.SnackbarDuration.Short, + ) + activeApplyServiceChangeMode = null + if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { + try { + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + withContext(Dispatchers.IO) { + Libbox.newStandaloneCommandClient().serviceReload() + } + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + restartServiceForApplyChange() + } + } + } catch (e: Exception) { + errorMessage = e.message ?: e.toString() + showErrorDialog = true + } + } + } + } + } // Groups Sheet state var showGroupsSheet by remember { mutableStateOf(false) } @@ -335,8 +423,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 +702,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 +718,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 +748,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 +770,7 @@ class MainActivity : add(Screen.Connections) } add(Screen.Log) + add(Screen.Tools) add(Screen.Settings) } @@ -681,6 +778,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 +837,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) } } } @@ -789,6 +870,7 @@ class MainActivity : logViewModel = logViewModel, groupsViewModel = groupsViewModel, connectionsViewModel = connectionsViewModel, + tailscaleStatusViewModel = tailscaleStatusViewModel, modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { @@ -919,6 +1001,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 +1029,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 +1077,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 +1293,30 @@ class MainActivity : showBackgroundLocationDialog = true } + private suspend fun restartServiceForApplyChange() { + if (currentServiceStatus != Status.Started) { + return + } + + BoxService.stop() + while (true) { + when (currentServiceStatus) { + Status.Stopped -> { + startService() + return + } + + Status.Starting -> { + return + } + + Status.Started, Status.Stopping -> { + delay(100L) + } + } + } + } + override fun onDestroy() { connection.disconnect() super.onDestroy() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt new file mode 100644 index 000000000..7f3161755 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt @@ -0,0 +1,16 @@ +package io.nekohasekai.sfa.compose.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.nekohasekai.sfa.constant.Status + +@Composable +fun rememberApplyServiceChangeNotifier( + serviceStatus: Status, +): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) { + { mode -> + if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 6b7467a32..70b1f3cbd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -19,7 +19,12 @@ sealed class UiEvent { object RequestReconnectService : UiEvent() - object RestartToTakeEffect : UiEvent() + data class ApplyServiceChange(val mode: Mode) : UiEvent() { + enum class Mode { + Reload, + Restart, + } + } } /** diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 27456b99c..9ae23cc11 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Terminal import androidx.compose.ui.graphics.vector.ImageVector import io.nekohasekai.sfa.R @@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I icon = Icons.Default.SwapVert, ) + object Tools : Screen( + route = "tools", + titleRes = R.string.title_tools, + icon = Icons.Default.Terminal, + ) + object Settings : Screen( route = "settings", titleRes = R.string.title_settings, @@ -46,5 +53,6 @@ val bottomNavigationScreens = listOf( Screen.Dashboard, Screen.Log, + Screen.Tools, Screen.Settings, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index d6e5f22b1..6357e5c10 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -33,6 +34,21 @@ 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.NetworkQualityScreen +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.OutboundPickerScreen +import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel +import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen import io.nekohasekai.sfa.constant.Status private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { @@ -64,6 +80,7 @@ fun SFANavHost( logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null, + tailscaleStatusViewModel: TailscaleStatusViewModel? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -210,6 +227,174 @@ fun SFANavHost( } } + composable(Screen.Tools.route) { + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel) + } + + // Tools subscreens with slide animations + composable( + route = "tools/network_quality", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + NetworkQualityScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/stun_test", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + STUNTestScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/outbound_picker/{selectedOutbound}", + arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val selectedOutbound = Uri.decode(backStackEntry.arguments?.getString("selectedOutbound") ?: "") + OutboundPickerScreen(navController = navController, selectedOutbound = selectedOutbound) + } + + composable( + route = "tools/tailscale/{endpointTag}", + arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) + } + + composable( + route = "tools/tailscale/{endpointTag}/peer/{peerId}", + arguments = listOf( + navArgument("endpointTag") { type = NavType.StringType }, + navArgument("peerId") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val peerId = Uri.decode(backStackEntry.arguments?.getString("peerId") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscalePeerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag, peerId = peerId) + } + + 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 +407,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - AppSettingsScreen(navController = navController) + AppSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -252,7 +437,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ServiceSettingsScreen(navController = navController) + ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -262,7 +447,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ProfileOverrideScreen(navController = navController) + ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -272,7 +457,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PerAppProxyScreen(onBack = { navController.navigateUp() }) + PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } composable( @@ -292,7 +477,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) + PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } composable( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt index 8734b431c..46b170f0d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.utils.PrivilegeSettingsClient @@ -95,10 +98,14 @@ private enum class RiskCategory { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { +fun PrivilegeSettingsManageScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortReverse by remember { mutableStateOf(false) } @@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { } if (failure != null) { syncErrorMessage = failure.message ?: failure.toString() + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt index 74b89866b..3c553b27b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.vendor.PackageQueryManager @@ -106,10 +109,14 @@ private sealed class ScanResult { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PerAppProxyScreen(onBack: () -> Unit) { +fun PerAppProxyScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } var sortMode by remember { mutableStateOf(SortMode.NAME) } @@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { fun saveSelectedApplications(newUids: Set) { coroutineScope.launch { - Settings.perAppProxyList = buildPackageList(newUids) + withContext(Dispatchers.IO) { + Settings.perAppProxyList = buildPackageList(newUids) + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } @@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { onModeChange = { mode -> proxyMode = mode coroutineScope.launch { - Settings.perAppProxyMode = mode + withContext(Dispatchers.IO) { + Settings.perAppProxyMode = mode + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } }, onSortModeChange = { mode -> diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 9c94f63a8..028d02142 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -87,8 +87,11 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.update.UpdateCheckException @@ -109,7 +112,10 @@ import android.provider.Settings as AndroidSettings @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun AppSettingsScreen(navController: NavController) { +fun AppSettingsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_app_settings)) }, @@ -155,6 +161,7 @@ fun AppSettingsScreen(navController: NavController) { var notificationEnabled by remember { mutableStateOf(true) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var showDisableNotificationDialog by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var showLanguageDialog by remember { mutableStateOf(false) } val availableLocales = remember { getSupportedLocales(context) } @@ -679,6 +686,9 @@ fun AppSettingsScreen(navController: NavController) { dynamicNotification = checked scope.launch(Dispatchers.IO) { Settings.dynamicNotification = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt index dbcf6bc42..0187c20f8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -62,9 +62,9 @@ import androidx.core.content.FileProvider import androidx.navigation.NavController import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings @@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status val context = LocalContext.current val scope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val systemHookStatus by HookStatusClient.status.collectAsState() var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } @@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (checked && serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 22370e8a5..6d1688bbe 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers @@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileOverrideScreen(navController: NavController) { +fun ProfileOverrideScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.profile_override)) }, @@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) { var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var isScanning by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) - fun scanAndSaveManagedList() { + fun scanAndSaveManagedList(shouldNotify: Boolean = false) { isScanning = true scope.launch { val chinaApps = PerAppProxyScanner.scanAllChinaApps() @@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyManagedList = chinaApps } isScanning = false + if (shouldNotify) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } @@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } @@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) { withContext(Dispatchers.IO) { Settings.autoRedirect = true } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } else { Toast.makeText( context, @@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) { autoRedirect = false scope.launch(Dispatchers.IO) { Settings.autoRedirect = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = checked scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = checked + if (!checked || !managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (checked && managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } } }, @@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = true } - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } else { managedModeEnabled = false scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = true scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = true + if (!managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } }, ) { @@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } else { showRootDialog = false @@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = false } } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( @@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index b6038d932..2171997f9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -57,15 +57,23 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.launchCustomTab import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { +fun ServiceSettingsScreen( + navController: NavController, + serviceConnection: ServiceConnection? = null, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.service)) }, @@ -84,6 +92,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi val scope = rememberCoroutineScope() var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } var allowBypass by remember { mutableStateOf(Settings.allowBypass) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val requestBatteryOptimizationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), @@ -255,6 +264,9 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi allowBypass = checked scope.launch(Dispatchers.IO) { Settings.allowBypass = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt new file mode 100644 index 000000000..bd7d71267 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -0,0 +1,459 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReport +import io.nekohasekai.sfa.bg.CrashReportFile +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportDetailScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = CrashReportManager.availableFiles(report) + } + CrashReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && CrashReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + CrashReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + CrashReportFile.Kind.METADATA -> Icons.Default.DataObject + CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal + CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport + CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + if (file.kind == CrashReportFile.Kind.METADATA) { + navController.navigate("tools/crash_report/$reportId/metadata") + } else { + navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}") + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportMetadataScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + ) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + key, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(value) + }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } + } + } +} + +private fun loadMetadataEntries(report: CrashReport): List> { + val metadataFile = CrashReportManager.availableFiles(report) + .find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull() + val displayName = when (kind) { + CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log) + CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log) + CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration) + else -> fileKind + } + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = CrashReportManager.availableFiles(report).find { it.kind == kind } + content = if (file != null) CrashReportManager.loadFileContent(file) else "" + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt new file mode 100644 index 000000000..aa238a00a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt @@ -0,0 +1,263 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.FlashOn +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportListScreen(navController: NavController) { + val reports by CrashReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + var crashTriggerExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + CrashReportManager.refresh() + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.crash_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (reports.isNotEmpty() || BuildConfig.DEBUG) { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { + menuExpanded = false + crashTriggerExpanded = false + }, + ) { + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("Crash Trigger") }, + leadingIcon = { + Icon( + Icons.Outlined.FlashOn, + contentDescription = null, + ) + }, + onClick = { crashTriggerExpanded = !crashTriggerExpanded }, + ) + if (crashTriggerExpanded) { + DropdownMenuItem( + text = { + Text( + "Go Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Libbox.triggerGoPanic() + }, + ) + DropdownMenuItem( + text = { + Text( + "Native Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + }, + ) + } + } + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { + CrashReportManager.deleteAll() + } + }, + ) + } + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/crash_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.crash_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt new file mode 100644 index 000000000..63d8963da --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt @@ -0,0 +1,470 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +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.DisposableEffect +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.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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkQualityScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: NetworkQualityViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + val context = LocalContext.current + + var showConfigURLDialog by remember { mutableStateOf(false) } + var showMaxRuntimeDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.network_quality)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (state.showMeteredWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissMeteredWarning() }, + title = { Text(stringResource(R.string.network_quality_metered_title)) }, + text = { Text(stringResource(R.string.network_quality_metered_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) { + Text(stringResource(R.string.network_quality_metered_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissMeteredWarning() }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showConfigURLDialog) { + ConfigURLDialog( + currentURL = state.configURL, + onURLChanged = { viewModel.updateConfigURL(it) }, + onDismiss = { showConfigURLDialog = false }, + ) + } + + if (showMaxRuntimeDialog) { + MaxRuntimeDialog( + currentOption = state.maxRuntime, + onOptionSelected = { + viewModel.setMaxRuntime(it) + showMaxRuntimeDialog = false + }, + onDismiss = { showMaxRuntimeDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showConfigURLDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.network_quality_url), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.configURL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_serial), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.serial, + onCheckedChange = { viewModel.setSerial(it) }, + enabled = !state.isRunning, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_http3), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.http3, + onCheckedChange = { viewModel.setHttp3(it) }, + enabled = !state.isRunning, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = !state.isRunning) { showMaxRuntimeDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.network_quality_max_runtime), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + stringResource(state.maxRuntime.labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.network_quality_cancel)) + } + } else { + Button( + onClick = { viewModel.requestStartTest(context, vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.network_quality_start)) + } + } + + if (state.phase >= 0) { + val phaseDownload = Libbox.NetworkQualityPhaseDownload.toInt() + val phaseUpload = Libbox.NetworkQualityPhaseUpload.toInt() + val downloadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseDownload + val uploadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseUpload + val done = state.phase == Libbox.NetworkQualityPhaseDone.toInt() + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.network_quality_idle_latency), + value = if (state.idleLatencyMs > 0) "${state.idleLatencyMs} ms" else null, + isActive = state.phase == Libbox.NetworkQualityPhaseIdle.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download), + value = if (state.downloadCapacity > 0) Libbox.formatBitrate(state.downloadCapacity) else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download_rpm), + value = if (state.downloadRPM > 0) "${state.downloadRPM}" else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadRPMAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload), + value = if (state.uploadCapacity > 0) Libbox.formatBitrate(state.uploadCapacity) else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload_rpm), + value = if (state.uploadRPM > 0) "${state.uploadRPM}" else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadRPMAccuracy).second else null, + ) + } + } + } + } +} + +@Composable +private fun accuracyLabel(value: Int): Pair = when (value) { + Libbox.NetworkQualityAccuracyHigh -> stringResource(R.string.network_quality_confidence_high) to Color.Green + Libbox.NetworkQualityAccuracyMedium -> stringResource(R.string.network_quality_confidence_medium) to Color.Yellow + else -> stringResource(R.string.network_quality_confidence_low) to Color.Red +} + +@Composable +private fun ConfigURLDialog( + currentURL: String, + onURLChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentURL) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_url)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onURLChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun MaxRuntimeDialog( + currentOption: MaxRuntimeOption, + onOptionSelected: (MaxRuntimeOption) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_max_runtime)) }, + text = { + Column { + MaxRuntimeOption.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onOptionSelected(option) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentOption == option, + onClick = { onOptionSelected(option) }, + ) + Text( + text = stringResource(option.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt new file mode 100644 index 000000000..30b0fbcfa --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt @@ -0,0 +1,216 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Context +import android.net.ConnectivityManager +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NetworkQualityProgress +import io.nekohasekai.libbox.NetworkQualityResult +import io.nekohasekai.libbox.NetworkQualityTestHandler +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class MaxRuntimeOption(val seconds: Int, val labelRes: Int) { + THIRTY(30, R.string.network_quality_max_runtime_30s), + SIXTY(60, R.string.network_quality_max_runtime_60s), +} + +data class NetworkQualityState( + val phase: Int = -1, + val idleLatencyMs: Int = 0, + val downloadCapacity: Long = 0, + val uploadCapacity: Long = 0, + val downloadRPM: Int = 0, + val uploadRPM: Int = 0, + val downloadCapacityAccuracy: Int = 0, + val uploadCapacityAccuracy: Int = 0, + val downloadRPMAccuracy: Int = 0, + val uploadRPMAccuracy: Int = 0, + val isRunning: Boolean = false, + val configURL: String = Libbox.NetworkQualityDefaultConfigURL, + val serial: Boolean = false, + val http3: Boolean = false, + val maxRuntime: MaxRuntimeOption = MaxRuntimeOption.THIRTY, + val selectedOutbound: String = "", + val showMeteredWarning: Boolean = false, +) + +class NetworkQualityViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.NetworkQualityTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = NetworkQualityState() + + fun updateConfigURL(url: String) { + updateState { copy(configURL = url) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun setSerial(value: Boolean) { + updateState { copy(serial = value) } + } + + fun setHttp3(value: Boolean) { + updateState { copy(http3 = value) } + } + + fun setMaxRuntime(option: MaxRuntimeOption) { + updateState { copy(maxRuntime = option) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun requestStartTest(context: Context, vpnRunning: Boolean) { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (connectivityManager.isActiveNetworkMetered) { + updateState { copy(showMeteredWarning = true) } + } else { + startTest(vpnRunning) + } + } + + fun dismissMeteredWarning() { + updateState { copy(showMeteredWarning = false) } + } + + fun confirmMeteredStart(vpnRunning: Boolean) { + updateState { copy(showMeteredWarning = false) } + startTest(vpnRunning) + } + + private fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + idleLatencyMs = 0, + downloadCapacity = 0, + uploadCapacity = 0, + downloadRPM = 0, + uploadRPM = 0, + downloadCapacityAccuracy = 0, + uploadCapacityAccuracy = 0, + downloadRPMAccuracy = 0, + uploadRPMAccuracy = 0, + isRunning = true, + ) + } + + val configURL = currentState.configURL + val outboundTag = currentState.selectedOutbound + val serial = currentState.serial + val http3 = currentState.http3 + val maxRuntimeSeconds = currentState.maxRuntime.seconds + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startNetworkQualityTest( + configURL, + outboundTag, + serial, + maxRuntimeSeconds, + http3, + handler, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newNetworkQualityTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(configURL, serial, maxRuntimeSeconds, http3, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): NetworkQualityTestHandler { + return object : NetworkQualityTestHandler { + override fun onProgress(progress: NetworkQualityProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + idleLatencyMs = progress.idleLatencyMs.toInt(), + downloadCapacity = progress.downloadCapacity, + uploadCapacity = progress.uploadCapacity, + downloadRPM = progress.downloadRPM.toInt(), + uploadRPM = progress.uploadRPM.toInt(), + downloadCapacityAccuracy = progress.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = progress.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = progress.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = progress.uploadRPMAccuracy.toInt(), + ) + } + } + } + + override fun onResult(result: NetworkQualityResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.NetworkQualityPhaseDone.toInt(), + idleLatencyMs = result.idleLatencyMs.toInt(), + downloadCapacity = result.downloadCapacity, + uploadCapacity = result.uploadCapacity, + downloadRPM = result.downloadRPM.toInt(), + uploadRPM = result.uploadRPM.toInt(), + downloadCapacityAccuracy = result.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = result.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = result.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = result.uploadRPMAccuracy.toInt(), + isRunning = false, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt new file mode 100644 index 000000000..f7936acb9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -0,0 +1,451 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReport +import io.nekohasekai.sfa.bg.OOMReportFile +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportDetailScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = OOMReportManager.availableFiles(report) + } + OOMReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && OOMReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + OOMReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + OOMReportFile.Kind.METADATA -> Icons.Default.DataObject + OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings + OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .then( + if (file.kind != OOMReportFile.Kind.PROFILE) { + Modifier.clickable { + if (file.kind == OOMReportFile.Kind.METADATA) { + navController.navigate("tools/oom_report/$reportId/metadata") + } else { + navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}") + } + } + } else { + Modifier + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportMetadataScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadOOMMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text(key, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { Text(value) }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } +} + +private fun loadOOMMetadataEntries(report: OOMReport): List> { + val metadataFile = OOMReportManager.availableFiles(report) + .find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(fileKind) } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull() + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = OOMReportManager.availableFiles(report).find { it.kind == kind } + if (file != null) { + displayName = file.displayName + content = OOMReportManager.loadFileContent(file) + } + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt new file mode 100644 index 000000000..94baff384 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt @@ -0,0 +1,416 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Date + +private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportListScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { + val reports by OOMReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf(null) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) + + var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) } + var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) } + var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) } + var showMemoryLimitDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + OOMReportManager.refresh() + val storedLimit = Settings.oomMemoryLimitMB + if (!memoryLimitOptions.contains(storedLimit)) { + oomMemoryLimitMB = memoryLimitOptions.first() + Settings.oomMemoryLimitMB = oomMemoryLimitMB + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.oom_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.oom_report_fetch)) }, + leadingIcon = { + Icon(Icons.Outlined.Memory, contentDescription = null) + }, + onClick = { + menuExpanded = false + if (serviceStatus != Status.Started) { + errorMessage = + Application.application.getString(R.string.service_not_started) + } else { + scope.launch { + val failure = + withContext(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().triggerOOMReport() + }.exceptionOrNull() + } + if (failure != null) { + errorMessage = failure.message ?: failure.toString() + } else { + delay(1000) + withContext(Dispatchers.IO) { + OOMReportManager.refresh() + } + } + } + } + }, + ) + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { OOMReportManager.deleteAll() } + }, + ) + } + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Reports section + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/oom_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.oom_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + // Settings section + Text( + stringResource(R.string.title_settings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_enable_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_enable_memory_limit_description)) + }, + trailingContent = { + Switch( + checked = oomKillerEnabled, + onCheckedChange = { checked -> + oomKillerEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerEnabled = checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + AnimatedVisibility(visible = oomKillerEnabled) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L)) + }, + modifier = Modifier.clickable { showMemoryLimitDialog = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_kill_connections), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_kill_connections_description)) + }, + trailingContent = { + Switch( + checked = oomKillerKillConnections, + onCheckedChange = { checked -> + oomKillerKillConnections = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerDisabled = !checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } + } + + errorMessage?.let { message -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + confirmButton = { + TextButton(onClick = { errorMessage = null }) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { Text(message) }, + ) + } + + if (showMemoryLimitDialog) { + AlertDialog( + onDismissRequest = { showMemoryLimitDialog = false }, + title = { Text(stringResource(R.string.oom_report_memory_limit)) }, + text = { + Column { + memoryLimitOptions.forEach { value -> + ListItem( + headlineContent = { + Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L)) + }, + leadingContent = { + RadioButton( + selected = value == oomMemoryLimitMB, + onClick = null, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + oomMemoryLimitMB = value + showMemoryLimitDialog = false + scope.launch(Dispatchers.IO) { + Settings.oomMemoryLimitMB = value + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt new file mode 100644 index 000000000..c20fa4cfa --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt @@ -0,0 +1,280 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.model.GroupItem +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class OutboundPickerViewModel : + ViewModel(), + CommandClient.Handler { + private val _outbounds = MutableStateFlow>(emptyList()) + val outbounds: StateFlow> = _outbounds.asStateFlow() + + private var commandClient: CommandClient? = null + + fun connect() { + disconnect() + commandClient = CommandClient( + viewModelScope, + CommandClient.ConnectionType.Outbounds, + this, + ) + commandClient?.connect() + } + + fun disconnect() { + commandClient?.disconnect() + commandClient = null + } + + override fun updateOutbounds(outbounds: List) { + _outbounds.value = outbounds.map { GroupItem(it) } + } + + override fun onCleared() { + disconnect() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboundPickerScreen( + navController: NavController, + selectedOutbound: String, +) { + val viewModel: OutboundPickerViewModel = viewModel() + val outbounds by viewModel.outbounds.collectAsState() + var searchText by rememberSaveable { mutableStateOf("") } + + DisposableEffect(Unit) { + viewModel.connect() + onDispose { + viewModel.disconnect() + } + } + + val filteredOutbounds = if (searchText.isEmpty()) { + outbounds + } else { + outbounds.filter { it.tag.contains(searchText, ignoreCase = true) } + } + + fun selectOutbound(tag: String) { + navController.previousBackStackEntry?.savedStateHandle?.set("selected_outbound", tag) + navController.navigateUp() + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tool_outbound)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + ) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + OutboundPickerItem( + tag = stringResource(R.string.tool_default_outbound), + isSelected = selectedOutbound.isEmpty(), + onClick = { selectOutbound("") }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(filteredOutbounds, key = { it.tag }) { item -> + OutboundPickerItem( + tag = item.tag, + type = Libbox.proxyDisplayType(item.type), + urlTestDelay = item.urlTestDelay, + isSelected = selectedOutbound == item.tag, + onClick = { selectOutbound(item.tag) }, + ) + } + } + } +} + +@Composable +private fun OutboundPickerItem( + tag: String, + type: String? = null, + urlTestDelay: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag, + style = MaterialTheme.typography.bodyLarge, + ) + if (type != null) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (urlTestDelay > 0) { + Text( + text = "${urlTestDelay}ms", + style = MaterialTheme.typography.bodySmall, + color = outboundDelayColor(urlTestDelay), + ) + } + } + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +fun OutboundPickerRow( + selectedOutbound: String, + onClick: () -> Unit, +) { + val displayText = if (selectedOutbound.isEmpty()) { + stringResource(R.string.tool_default_outbound) + } else { + selectedOutbound + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.tool_outbound), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun outboundDelayColor(delay: Int): Color { + val colorScheme = MaterialTheme.colorScheme + return when { + delay < 100 -> colorScheme.tertiary + delay < 300 -> colorScheme.primary + delay < 500 -> colorScheme.secondary + else -> colorScheme.error + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt new file mode 100644 index 000000000..24c02e4fd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt @@ -0,0 +1,78 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +fun ResultItem( + label: String, + value: String?, + isActive: Boolean, + isRunning: Boolean, + accuracy: String? = null, + valueColor: Color? = null, + accuracyColor: Color? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + when { + value != null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRunning && isActive) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = valueColor ?: Color.Unspecified, + ) + if (accuracy != null) { + Text( + accuracy, + style = MaterialTheme.typography.labelSmall, + color = accuracyColor ?: MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + isRunning && isActive -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + else -> { + Text( + "-", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt new file mode 100644 index 000000000..968128367 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt @@ -0,0 +1,317 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun STUNTestScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: STUNTestViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + + var showServerDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.stun_test)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (showServerDialog) { + ServerEditDialog( + currentServer = state.server, + onServerChanged = { viewModel.updateServer(it) }, + onDismiss = { showServerDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showServerDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.stun_server), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.server, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.stun_cancel)) + } + } else { + Button( + onClick = { viewModel.startTest(vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.stun_start)) + } + } + + if (state.phase >= 0) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.stun_external_address), + value = state.externalAddr.ifEmpty { null }, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_latency), + value = if (state.latencyMs > 0) "${state.latencyMs} ms" else null, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + if (state.phase == Libbox.STUNPhaseDone.toInt() && !state.natTypeSupported) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_type_detection), + value = stringResource(R.string.stun_nat_not_supported), + isActive = false, + isRunning = false, + ) + } else { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_mapping), + value = if (state.natMapping > 0) Libbox.formatNATMapping(state.natMapping) else null, + isActive = state.phase == Libbox.STUNPhaseNATMapping.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natMapping > 0) natMappingColor(state.natMapping) else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_filtering), + value = if (state.natFiltering > 0) Libbox.formatNATFiltering(state.natFiltering) else null, + isActive = state.phase == Libbox.STUNPhaseNATFiltering.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natFiltering > 0) natFilteringColor(state.natFiltering) else null, + ) + } + } + } + } + } +} + +private fun natMappingColor(value: Int): Color = when (value) { + Libbox.NATMappingEndpointIndependent.toInt() -> Color.Green + Libbox.NATMappingAddressDependent.toInt() -> Color.Yellow + Libbox.NATMappingAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +private fun natFilteringColor(value: Int): Color = when (value) { + Libbox.NATFilteringEndpointIndependent.toInt() -> Color.Green + Libbox.NATFilteringAddressDependent.toInt() -> Color.Yellow + Libbox.NATFilteringAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +@Composable +private fun ServerEditDialog( + currentServer: String, + onServerChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentServer) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.stun_server)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onServerChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt new file mode 100644 index 000000000..0b7405ca8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt @@ -0,0 +1,146 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.STUNTestHandler +import io.nekohasekai.libbox.STUNTestProgress +import io.nekohasekai.libbox.STUNTestResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class STUNTestState( + val phase: Int = -1, + val externalAddr: String = "", + val latencyMs: Int = 0, + val natMapping: Int = 0, + val natFiltering: Int = 0, + val natTypeSupported: Boolean = false, + val isRunning: Boolean = false, + val server: String = Libbox.STUNDefaultServer, + val selectedOutbound: String = "", +) + +class STUNTestViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.STUNTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = STUNTestState() + + fun updateServer(server: String) { + updateState { copy(server = server) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + externalAddr = "", + latencyMs = 0, + natMapping = 0, + natFiltering = 0, + natTypeSupported = false, + isRunning = true, + ) + } + + val server = currentState.server + val outboundTag = currentState.selectedOutbound + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startSTUNTest(server, outboundTag, handler) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newSTUNTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(server, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): STUNTestHandler { + return object : STUNTestHandler { + override fun onProgress(progress: STUNTestProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + externalAddr = progress.externalAddr, + latencyMs = progress.latencyMs.toInt(), + natMapping = progress.natMapping.toInt(), + natFiltering = progress.natFiltering.toInt(), + ) + } + } + } + + override fun onResult(result: STUNTestResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.STUNPhaseDone.toInt(), + isRunning = false, + externalAddr = result.externalAddr, + latencyMs = result.latencyMs.toInt(), + natMapping = result.natMapping.toInt(), + natFiltering = result.natFiltering.toInt(), + natTypeSupported = result.natTypeSupported, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt new file mode 100644 index 000000000..60e309061 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -0,0 +1,362 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.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.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.QRCodeGenerator + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleEndpointScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(endpointTag) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + val context = LocalContext.current + var showAuthQRCode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + val hasNetwork = endpoint.networkName.isNotEmpty() + val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() + val hasAuth = endpoint.authURL.isNotEmpty() + + // Status section + SectionHeader(stringResource(R.string.tailscale_status)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_state), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(stateColor(endpoint.backendState)), + ) + Text( + endpoint.backendState, + style = MaterialTheme.typography.bodyMedium, + color = stateColor(endpoint.backendState), + ) + } + }, + modifier = Modifier.clip( + if (stateIsLast) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + if (hasNetwork) { + val networkIsLast = !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_network), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.networkName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (networkIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasMagicDNS) { + val magicDNSIsLast = !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_magic_dns), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.magicDNSSuffix, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (magicDNSIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasAuth) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(endpoint.authURL))) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url_qr_code), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { showAuthQRCode = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + // This Device section + if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_this_device)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + PeerItem( + peer = endpoint.selfPeer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + ) + } + } + + // User group sections + for (group in endpoint.userGroups) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(group.displayName.ifEmpty { group.loginName }) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + group.peers.forEachIndexed { index, peer -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + PeerItem( + peer = peer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}", + ) + }, + modifier = when { + group.peers.size == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) + index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + index == group.peers.lastIndex -> Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + else -> Modifier + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (showAuthQRCode && endpoint.authURL.isNotEmpty()) { + val qrBitmap = QRCodeGenerator.rememberBitmap(endpoint.authURL) + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { showAuthQRCode = false }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun PeerItem( + peer: TailscalePeerData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text( + peer.hostName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = if (peer.tailscaleIPs.isNotEmpty()) { + { + Text( + peer.tailscaleIPs.first(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + null + }, + leadingContent = { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + }, + modifier = modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +private fun stateColor(state: String): Color = when (state) { + "Running" -> Color(0xFF4CAF50) + "NeedsLogin", "NeedsMachineAuth" -> Color(0xFFFF9800) + "Starting" -> Color(0xFFFFEB3B) + else -> Color.Gray +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt new file mode 100644 index 000000000..c82118652 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt @@ -0,0 +1,460 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.Check +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.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.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.ktx.clipboardText +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscalePeerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, + peerId: String, +) { + val state by viewModel.uiState.collectAsState() + val peer = viewModel.peer(endpointTag, peerId) + val isSelf = viewModel.endpoint(endpointTag)?.selfPeer?.id == peerId + val pingViewModel: TailscalePingViewModel = viewModel() + val pingState by pingViewModel.uiState.collectAsState() + + DisposableEffect(Unit) { + onDispose { + if (pingState.isRunning) { + pingViewModel.stopPing() + } + } + } + + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + peer?.hostName ?: "", + style = MaterialTheme.typography.titleMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (peer?.online == true) Color(0xFF4CAF50) else Color.Gray, + ), + ) + Text( + if (peer?.online == true) { + stringResource(R.string.tailscale_connected) + } else { + stringResource(R.string.tailscale_not_connected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + if (peer == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var copiedAddress by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Tailscale Addresses section + SectionHeader(stringResource(R.string.tailscale_addresses)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.dnsName.isNotEmpty()) { + AddressRow( + address = Libbox.formatFQDN(peer.dnsName), + label = stringResource(R.string.tailscale_magic_dns), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + for (ip in peer.tailscaleIPs) { + AddressRow( + address = ip, + label = if (ip.contains(":")) { + stringResource(R.string.tailscale_ipv6) + } else { + stringResource(R.string.tailscale_ipv4) + }, + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + } + + // Ping section (not for self peer) + if (!isSelf && peer.online && peer.tailscaleIPs.isNotEmpty()) { + val peerIP = peer.tailscaleIPs.first() + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tailscale_ping), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Surface( + onClick = { + if (pingState.isRunning) { + pingViewModel.stopPing() + } else { + pingViewModel.startPing(endpointTag, peerIP) + } + }, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(width = 44.dp, height = 32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (pingState.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = if (pingState.isRunning) { + stringResource(R.string.tailscale_ping_stop) + } else { + stringResource(R.string.tailscale_ping_start) + }, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + if (pingState.hasResult) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (pingState.isDirect) { + Text( + text = "\u2192 ", + color = Color(0xFF4CAF50), + ) + Text( + text = stringResource(R.string.tailscale_ping_direct), + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = "\u21BB ", + color = Color(0xFFFF9800), + ) + Text( + text = stringResource(R.string.tailscale_ping_derp), + color = Color(0xFFFF9800), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${pingState.latencyMs.toInt()} ms", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + if (pingState.isRunning && pingState.latencyHistory.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + LineChart( + data = pingState.latencyHistory, + lineColor = if (pingState.isDirect) { + Color(0xFF4CAF50) + } else { + Color(0xFF2196F3) + }, + animate = false, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + val maxMs = ( + ( + pingState.latencyHistory.maxOrNull() + ?: 1f + ) * 1.2f + ).toInt().coerceAtLeast(1) + Column( + modifier = Modifier.height(80.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${maxMs}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs * 2 / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "0ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + Text( + text = "No data", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Details section + val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode + if (showDetails) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_details)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + val context = LocalContext.current + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.keyExpiry > 0) { + val expiryText = DateUtils.getRelativeTimeSpanString( + peer.keyExpiry * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = expiryText, + ) + } + if (peer.os.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_os), + value = peer.os, + ) + } + if (peer.exitNode) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_active), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + LaunchedEffect(copiedAddress) { + if (copiedAddress != null) { + delay(2000) + copiedAddress = null + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun AddressRow( + address: String, + label: String, + copied: String?, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + address, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { + clipboardText = address + onCopy(address) + }) { + if (copied == address) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt new file mode 100644 index 000000000..9109e261d --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscalePingHandler +import io.nekohasekai.libbox.TailscalePingResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class TailscalePingState( + val isRunning: Boolean = false, + val hasResult: Boolean = false, + val latencyMs: Double = 0.0, + val isDirect: Boolean = false, + val derpRegionCode: String = "", + val endpoint: String = "", + val latencyHistory: List = emptyList(), +) + +class TailscalePingViewModel : BaseViewModel() { + private val maxHistorySize = 30 + private var commandClient: CommandClient? = null + private var grpcJob: Job? = null + + override fun createInitialState() = TailscalePingState() + + fun startPing(endpointTag: String, peerIP: String) { + updateState { + copy( + isRunning = true, + hasResult = false, + latencyHistory = emptyList(), + ) + } + + val client = Libbox.newStandaloneCommandClient() + commandClient = client + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + client.startTailscalePing( + endpointTag, + peerIP, + object : TailscalePingHandler { + override fun onPingResult(result: TailscalePingResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + if (result.error.isNotEmpty()) return@launch + val newHistory = currentState.latencyHistory.toMutableList() + newHistory.add(result.latencyMs.toFloat()) + if (newHistory.size > maxHistorySize) { + newHistory.removeFirst() + } + updateState { + copy( + hasResult = true, + latencyMs = result.latencyMs, + isDirect = result.isDirect, + derpRegionCode = result.derpRegionCode, + endpoint = result.endpoint, + latencyHistory = newHistory, + ) + } + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + }, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + } + } + + fun stopPing() { + grpcJob?.cancel() + grpcJob = null + try { + commandClient?.disconnect() + } catch (_: Exception) { + } + commandClient = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + super.onCleared() + stopPing() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt new file mode 100644 index 000000000..1de43e483 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt @@ -0,0 +1,180 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscaleStatusHandler +import io.nekohasekai.libbox.TailscaleStatusUpdate +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +data class TailscalePeerData( + val id: String, + val hostName: String, + val dnsName: String, + val os: String, + val tailscaleIPs: List, + val online: Boolean, + val exitNode: Boolean, + val exitNodeOption: Boolean, + val active: Boolean, + val rxBytes: Long, + val txBytes: Long, + val keyExpiry: Long, +) + +data class TailscaleUserGroupData( + val id: Long, + val loginName: String, + val displayName: String, + val profilePicURL: String, + val peers: List, +) + +data class TailscaleEndpointData( + val endpointTag: String, + val backendState: String, + val authURL: String, + val networkName: String, + val magicDNSSuffix: String, + val selfPeer: TailscalePeerData?, + val userGroups: List, +) + +data class TailscaleStatusState( + val endpoints: List = emptyList(), + val isSubscribed: Boolean = false, +) + +class TailscaleStatusViewModel : BaseViewModel() { + private var grpcJob: Job? = null + + override fun createInitialState() = TailscaleStatusState() + + fun subscribe() { + if (currentState.isSubscribed) return + updateState { copy(isSubscribed = true) } + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .subscribeTailscaleStatus(object : TailscaleStatusHandler { + override fun onStatusUpdate(status: TailscaleStatusUpdate) { + val endpoints = convertUpdate(status) + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = endpoints) } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + sendErrorMessage(message) + } + } + }) + } catch (_: Exception) { + viewModelScope.launch { + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + } + } + } + } + + fun cancel() { + grpcJob?.cancel() + grpcJob = null + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + } + + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } + + fun peer(endpointTag: String, peerId: String): TailscalePeerData? { + val ep = endpoint(endpointTag) ?: return null + if (ep.selfPeer?.id == peerId) return ep.selfPeer + for (group in ep.userGroups) { + val found = group.peers.firstOrNull { it.id == peerId } + if (found != null) return found + } + return null + } + + override fun onCleared() { + cancel() + super.onCleared() + } + + private fun convertUpdate(status: TailscaleStatusUpdate): List { + val endpoints = mutableListOf() + val iterator = status.endpoints() + while (iterator.hasNext()) { + endpoints.add(convertEndpoint(iterator.next())) + } + return endpoints + } + + private fun convertEndpoint( + endpoint: io.nekohasekai.libbox.TailscaleEndpointStatus, + ): TailscaleEndpointData { + val userGroups = mutableListOf() + val groupIterator = endpoint.userGroups() + while (groupIterator.hasNext()) { + userGroups.add(convertUserGroup(groupIterator.next())) + } + val self = endpoint.getSelf() + return TailscaleEndpointData( + endpointTag = endpoint.endpointTag, + backendState = endpoint.backendState, + authURL = endpoint.authURL, + networkName = endpoint.networkName, + magicDNSSuffix = endpoint.magicDNSSuffix, + selfPeer = if (self != null) convertPeer(self) else null, + userGroups = userGroups, + ) + } + + private fun convertUserGroup( + group: io.nekohasekai.libbox.TailscaleUserGroup, + ): TailscaleUserGroupData { + val peers = mutableListOf() + val peerIterator = group.peers() + while (peerIterator.hasNext()) { + peers.add(convertPeer(peerIterator.next())) + } + return TailscaleUserGroupData( + id = group.userID, + loginName = group.loginName, + displayName = group.displayName, + profilePicURL = group.profilePicURL, + peers = peers, + ) + } + + private fun convertPeer(peer: io.nekohasekai.libbox.TailscalePeer): TailscalePeerData { + val ips = mutableListOf() + val ipIterator = peer.tailscaleIPs() + while (ipIterator.hasNext()) { + ips.add(ipIterator.next()) + } + val dnsName = peer.getDNSName() + return TailscalePeerData( + id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, + hostName = peer.hostName, + dnsName = dnsName, + os = peer.getOS(), + tailscaleIPs = ips, + online = peer.online, + exitNode = peer.exitNode, + exitNodeOption = peer.exitNodeOption, + active = peer.active, + rxBytes = peer.rxBytes, + txBytes = peer.txBytes, + keyExpiry = peer.keyExpiry, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt new file mode 100644 index 000000000..b0e0207d1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -0,0 +1,253 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.net.Uri +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.Hub +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.NetworkCheck +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.LaunchedEffect +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 +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ToolsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + tailscaleViewModel: TailscaleStatusViewModel, +) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_tools)) }, + ) + } + + val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() + val oomUnreadCount by OOMReportManager.unreadCount.collectAsState() + val tailscaleState by tailscaleViewModel.uiState.collectAsState() + + LaunchedEffect(serviceStatus) { + if (serviceStatus == Status.Started) { + tailscaleViewModel.subscribe() + } else { + tailscaleViewModel.cancel() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + if (tailscaleState.endpoints.isNotEmpty()) { + Text( + text = stringResource(R.string.tailscale_endpoints), + 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, + ), + ) { + val endpoints = tailscaleState.endpoints + endpoints.forEachIndexed { index, endpoint -> + val shape = when { + endpoints.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == endpoints.size - 1 -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + if (endpoints.size == 1) { + stringResource(R.string.tailscale) + } else { + stringResource(R.string.tailscale_with_tag, endpoint.endpointTag) + }, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Hub, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/tailscale/${Uri.encode(endpoint.endpointTag)}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + Text( + text = stringResource(R.string.title_network), + 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.network_quality), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NetworkCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/network_quality") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.stun_test), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NetworkCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/stun_test") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + + Text( + text = stringResource(R.string.title_debug), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.crash_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (crashUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$crashUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/crash_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Memory, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (oomUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$oomUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/oom_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 2f41fea39..8454278d9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -31,6 +31,11 @@ object SettingsKey { const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" + // OOM killer + const val OOM_KILLER_ENABLED = "oom_killer_enabled" + const val OOM_KILLER_DISABLED = "oom_killer_disabled" + const val OOM_MEMORY_LIMIT_MB = "oom_memory_limit_mb" + // dashboard const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 64f417f75..721ac019a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -106,6 +106,10 @@ object Settings { ) { false } var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } + var oomKillerEnabled by dataStore.boolean(SettingsKey.OOM_KILLER_ENABLED) { false } + var oomKillerDisabled by dataStore.boolean(SettingsKey.OOM_KILLER_DISABLED) { true } + var oomMemoryLimitMB by dataStore.int(SettingsKey.OOM_MEMORY_LIMIT_MB) { 50 } + var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 3d5abf9fc..b70bdd808 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.OutboundGroupItemIterator import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator @@ -29,6 +30,7 @@ open class CommandClient( private val additionalHandlers = mutableListOf() private var cachedGroups: MutableList? = null + private var cachedOutbounds: List? = null fun addHandler(handler: Handler) { synchronized(additionalHandlers) { @@ -37,6 +39,9 @@ open class CommandClient( cachedGroups?.let { groups -> handler.updateGroups(groups) } + cachedOutbounds?.let { outbounds -> + handler.updateOutbounds(outbounds) + } } } } @@ -57,6 +62,7 @@ open class CommandClient( Log, ClashMode, Connections, + Outbounds, } interface Handler { @@ -74,6 +80,8 @@ open class CommandClient( fun updateGroups(newGroups: MutableList) {} + fun updateOutbounds(outbounds: List) {} + fun initializeClashMode(modeList: List, currentMode: String) {} fun updateClashMode(newMode: String) {} @@ -95,6 +103,7 @@ open class CommandClient( ConnectionType.Log -> Libbox.CommandLog ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds } options.addCommand(command) } @@ -142,6 +151,18 @@ open class CommandClient( getAllHandlers().forEach { it.updateGroups(groups) } } + override fun writeOutbounds(message: OutboundGroupItemIterator?) { + if (message == null) { + return + } + val outbounds = mutableListOf() + while (message.hasNext()) { + outbounds.add(message.next()) + } + cachedOutbounds = outbounds + getAllHandlers().forEach { it.updateOutbounds(outbounds) } + } + override fun setDefaultLogLevel(level: Int) { getAllHandlers().forEach { it.setDefaultLogLevel(level) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt index 08d4c2b5c..694b58017 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.xposed -import android.content.Context import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel @@ -11,16 +10,11 @@ object HookInstaller { private const val TAG = "XposedInit" - private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") } - private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") } - private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") } - fun install(classLoader: ClassLoader) { - val systemContext = resolveSystemContext() - HookErrorStore.i(TAG, "handleSystemServerLoaded") + HookErrorStore.i(TAG, "handleSystemServerStarting") val hooks = arrayOf( ConnectivityServiceHookHelper(classLoader), - HookIConnectivityManagerOnTransact(classLoader, systemContext), + HookIConnectivityManagerOnTransact(classLoader), HookPackageManagerGetInstalledPackages(classLoader), HookNetworkCapabilitiesWriteToParcel(), HookNetworkInterfaceGetName(classLoader), @@ -38,12 +32,4 @@ object HookInstaller { } } } - - private fun resolveSystemContext(): Context? = try { - val currentThread = currentActivityThreadMethod.invoke(null) - getSystemContextMethod.invoke(currentThread) as? Context - } catch (e: Throwable) { - HookErrorStore.e(TAG, "resolveSystemContext failed", e) - null - } } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt index 522da95e6..363474e4f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt @@ -1,12 +1,16 @@ package io.nekohasekai.sfa.xposed -import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface +import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam +import io.github.libxposed.api.XposedModuleInterface.SystemServerStartingParam -class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { +class XposedInit : XposedModule() { - override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { + override fun onModuleLoaded(param: ModuleLoadedParam) { + HookErrorStore.i("XposedInit", "onModuleLoaded process=${param.processName} system=${param.isSystemServer}") + } + + override fun onSystemServerStarting(param: SystemServerStartingParam) { HookInstaller.install(param.classLoader) } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt deleted file mode 100644 index e5504a8aa..000000000 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.nekohasekai.sfa.xposed - -import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface - -class XposedInit101 : XposedModule() { - - override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { - HookInstaller.install(param.classLoader) - } -} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt index 707e7f7b8..071d361b8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.xposed.hooks -import android.content.Context import android.content.pm.PackageInfo import android.os.Binder import android.os.Parcel @@ -12,8 +11,9 @@ import io.nekohasekai.sfa.xposed.HookErrorStore import io.nekohasekai.sfa.xposed.HookStatusKeys import io.nekohasekai.sfa.xposed.HookStatusStore import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.VpnAppStore -class HookIConnectivityManagerOnTransact(private val classLoader: ClassLoader, private val context: Context?) : XHook { +class HookIConnectivityManagerOnTransact(private val classLoader: ClassLoader) : XHook { private companion object { private const val SOURCE = "HookIConnectivityManagerOnTransact" } @@ -109,17 +109,8 @@ class HookIConnectivityManagerOnTransact(private val classLoader: ClassLoader, p private fun isCallerAllowed(): Boolean { val uid = Binder.getCallingUid() if (uid == 0) return true - val pm = context?.packageManager - if (pm == null) { - HookErrorStore.e(SOURCE, "isCallerAllowed: context or packageManager is null, uid=$uid") - return false - } return try { - val packages = pm.getPackagesForUid(uid) - if (packages == null) { - HookErrorStore.w(SOURCE, "isCallerAllowed: getPackagesForUid returned null for uid=$uid") - return false - } + val packages = VpnAppStore.getPackagesForUid(uid) packages.any { it == BuildConfig.APPLICATION_ID } } catch (e: Throwable) { HookErrorStore.e(SOURCE, "isCallerAllowed failed for uid=$uid", e) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index db997c6a9..7c0215f9d 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -23,6 +23,8 @@ اقدام شروع لغو انتخاب + بارگذاری مجدد + راه‌اندازی مجدد باز کردن جمع کردن باز کردن همه @@ -200,6 +202,8 @@ پوشه کاری تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ + اندازه حافظه پنهان + پاک‌سازی حافظه پنهان اعلان‌ها فعال‌کردن اعلان نمایش سرعت بلادرنگ در اعلان @@ -279,6 +283,22 @@ نسخه جدید موجود است: %s به‌روزرسانی خودکار دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه + منبع به‌روزرسانی + GitHub + F-Droid + آینه F-Droid + انتخاب خودکار بر اساس تأخیر + در حال تست… + %d ms + ناموفق + + افزودن آینه + نام + URL + سفارشی + URL نامعتبر + افزودن + حذف نصب بی‌صدا @@ -406,6 +426,94 @@ جمع کردن جستجو جستجوی لاگ‌ها + + ابزارها + شبکه + کیفیت شبکه + URL + ترتیبی + HTTP/3 + حداکثر زمان اجرا + 30s + 60s + شروع تست + لغو تست + تأخیر بیکاری + دانلود + آپلود + RPM دانلود + RPM آپلود + اطمینان بالا + اطمینان متوسط + اطمینان پایین + اتصال محدود + شما از اتصال محدود استفاده می‌کنید. این تست حجم قابل توجهی داده مصرف خواهد کرد. + ادامه + پیکربندی + نتایج + خروجی + پیش‌فرض + + + + نقاط اتصال + + وضعیت + وضعیت + شبکه + باز کردن لینک احراز هویت + نمایش QR کد لینک احراز هویت + این دستگاه + متصل + متصل نیست + آدرس‌های Tailscale + جزئیات + انقضای کلید + گره خروجی + فعال + + تست STUN + سرور + شروع تست + لغو تست + آدرس خارجی + تأخیر + نگاشت NAT + فیلتر NAT + تشخیص نوع NAT + پشتیبانی نمی‌شود توسط سرور + + + خالی + گزارش‌ها + فایل‌ها + حذف همه + حذف + اشتراک‌گذاری + اشتراک‌گذاری با پیکربندی + فراداده + پیکربندی + محلی + سرویس شروع نشده است + برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است + برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است + + + گزارش خرابی + Go Crash Log + JVM Crash Log + هنگام بروز خرابی گزارشی دریافت خواهید کرد. + + + گزارش کمبود حافظه + هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید. + دریافت گزارش حافظه + فعال‌سازی محدودیت حافظه + یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند. + محدودیت حافظه + قطع اتصالات + هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید. + بهبود دسترسی ویژه برای sing-box diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 274a39d7b..50fc5e4a1 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -23,6 +23,8 @@ Действие Начать Отменить выбор + Перезагрузить + Перезапустить Развернуть Свернуть Развернуть все @@ -200,6 +202,8 @@ Рабочая директория Бета-настройки Отключить предупреждения об устаревании + Размер кэша + Очистить кэш Уведомления Включить уведомления Отображать скорость в реальном времени в уведомлении @@ -279,6 +283,22 @@ Доступна новая версия: %s Автообновление Автоматически загружать и устанавливать обновления в фоне + Источник обновлений + GitHub + F-Droid + Зеркало F-Droid + Автовыбор по задержке + Тестирование… + %d мс + Ошибка + + Добавить зеркало + Имя + URL + Пользовательское + Недопустимый URL + Добавить + Удалить Тихая установка @@ -412,6 +432,94 @@ Свернуть поиск Поиск в логе + + Инструменты + Сеть + Качество сети + URL + Последовательно + HTTP/3 + Макс. время + 30s + 60s + Начать тест + Остановить тест + Задержка в простое + Загрузка + Отправка + Загрузка RPM + Отправка RPM + Высокая уверенность + Средняя уверенность + Низкая уверенность + Лимитное подключение + Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика. + Продолжить + Конфигурация + Результаты + Исходящий + По умолчанию + + + + Точки подключения + + Статус + Состояние + Сеть + Открыть URL авторизации + Показать QR-код авторизации + Это устройство + Подключено + Не подключено + Адреса Tailscale + Подробности + Срок действия ключа + Выходной узел + Активен + + STUN-тест + Сервер + Начать тест + Остановить тест + Внешний адрес + Задержка + NAT-отображение + NAT-фильтрация + Определение типа NAT + Не поддерживается сервером + + + Пусто + Отчёты + Файлы + Удалить все + Удалить + Поделиться + Поделиться с конфигурацией + Метаданные + Конфигурация + Локальный + Служба не запущена + Для применения изменений необходимо перезагрузить сервис + Для применения изменений необходимо перезапустить сервис + + + Отчёт о сбое + Go Crash Log + JVM Crash Log + Вы получите отчёт при возникновении сбоя. + + + Отчёт о нехватке памяти + При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта. + Получить отчёт о памяти + Включить ограничение памяти + Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения. + Ограничение памяти + Завершить соединения + Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса. + Привилегированное расширение для sing-box diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f7df1a26..a6ad323f2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,6 +23,8 @@ 操作 启动 取消选择 + 重载 + 重启 展开 收起 全部展开 @@ -66,7 +68,7 @@ 已启动 - 仪表项目 + 仪表项 内存 协程 上传 @@ -86,7 +88,7 @@ 搜索连接… 关闭所有连接? 全部 - 活跃 + 活动 已关闭 日期 流量 @@ -421,6 +423,94 @@ 折叠搜索 搜索日志 + + 工具 + 网络 + 网络质量 + URL + 串行 + HTTP/3 + 最大运行时间 + 30s + 60s + 开始测试 + 取消测试 + 空闲延迟 + 下载 + 上传 + 下载 RPM + 上传 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量计费连接 + 您正在使用按流量计费的连接。此测试将消耗大量数据。 + 继续 + 配置 + 结果 + 出站 + 默认 + + + + 端点 + + 状态 + 状态 + 网络 + 打开认证链接 + 显示认证链接二维码 + 此设备 + 已连接 + 未连接 + Tailscale 地址 + 详情 + 密钥过期 + 出口节点 + 活跃 + + STUN 测试 + 服务器 + 开始测试 + 取消测试 + 外部地址 + 延迟 + NAT 映射 + NAT 过滤 + NAT 类型检测 + 服务器不支持 + + + + 报告 + 文件 + 全部删除 + 删除 + 分享 + 附带配置分享 + 元数据 + 配置 + 本地 + 服务未启动 + 需要重载服务以应用更改 + 需要重启服务以应用更改 + + + 崩溃报告 + Go Crash Log + JVM Crash Log + 当遇到崩溃时,您将会收到报告。 + + + 内存不足报告 + 启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。 + 获取内存报告 + 启用内存限制 + 为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。 + 内存限制 + 终止连接 + 当服务内存超出限制时,终止所有连接以释放内存。 + sing-box 的特权增强 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e0d79aa33..f3208e706 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -23,6 +23,8 @@ 操作 啟動 取消選擇 + 重新載入 + 重新啟動 展開 收合 全部展開 @@ -45,7 +47,7 @@ 預設 - 儀表板 + 儀表 設定檔 日誌 設定 @@ -66,7 +68,7 @@ 已啟動 - 儀表板項目 + 儀表項 記憶體 協程 上傳 @@ -86,7 +88,7 @@ 搜尋連線… 關閉所有連線? 全部 - 活躍 + 活動 已關閉 日期 流量 @@ -424,6 +426,94 @@ 收合搜尋 搜尋日誌 + + 工具 + 網路 + 網路品質 + URL + 序列 + HTTP/3 + 最大執行時間 + 30s + 60s + 開始測試 + 取消測試 + 閒置延遲 + 下載 + 上傳 + 下載 RPM + 上傳 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量計費連線 + 您正在使用按流量計費的連線。此測試將消耗大量數據。 + 繼續 + 配置 + 結果 + 出站 + 默認 + + + + 端點 + + 狀態 + 狀態 + 網路 + 開啟認證連結 + 顯示認證連結 QR 碼 + 此裝置 + 已連線 + 未連線 + Tailscale 位址 + 詳情 + 金鑰到期 + 出口節點 + 活躍 + + STUN 測試 + 伺服器 + 開始測試 + 取消測試 + 外部地址 + 延遲 + NAT 映射 + NAT 過濾 + NAT 類型偵測 + 伺服器不支援 + + + + 報告 + 檔案 + 全部刪除 + 刪除 + 分享 + 附帶配置分享 + 元數據 + 配置 + 本地 + 服務未啟動 + 需要重新載入服務以套用變更 + 需要重新啟動服務以套用變更 + + + 當機報告 + Go Crash Log + JVM Crash Log + 當發生當機時,您將會收到報告。 + + + 記憶體不足報告 + 啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。 + 取得記憶體報告 + 啟用記憶體限制 + 為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。 + 記憶體限制 + 終止連線 + 當服務記憶體超出限制時,終止所有連線以釋放記憶體。 + sing-box 的特權強化 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 064ccb764..70e31feae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Action Start Deselect + Reload + Restart Expand Collapse Expand All @@ -424,6 +426,105 @@ Collapse search Search logs + + Tools + Network + Network Quality + URL + Serial + HTTP/3 + Max Runtime + 30s + 60s + Start Test + Cancel Test + Idle Latency + Download + Upload + Download RPM + Upload RPM + Confidence High + Confidence Medium + Confidence Low + Metered Connection + You\'re on a metered connection. This test will use a significant amount of data. + Continue + Configuration + Results + Outbound + Default + + + Tailscale + Tailscale: %s + Endpoints + + Status + State + Network + MagicDNS + Open Auth URL + Show Auth URL QR Code + This Device + Connected + Not Connected + Tailscale Addresses + Details + Key Expiry + OS + Exit Node + Active + IPv4 + IPv6 + Ping + Start + Stop + Direct connection + DERP-relayed connection + + + STUN Test + Server + Start Test + Cancel Test + External Address + Latency + NAT Mapping + NAT Filtering + NAT Type Detection + Not supported by server + + + Empty + Reports + Files + Delete All + Delete + Share + Share With Configuration + Metadata + Configuration + Local + Service not started + Reload service to apply changes + Restart service to apply changes + + + Crash Report + Go Crash Log + JVM Crash Log + You will receive a report when a crash occurs. + + + OOM Report + When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection. + Fetch Memory Report + Enable Memory Limit + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + Memory Limit + Kill Connections + Kill all connections to free memory when the service memory exceeds the limit. + Privileged Enhancement for sing-box diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list index 06d02397d..54a737358 100644 --- a/app/src/main/resources/META-INF/xposed/java_init.list +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -1,2 +1 @@ io.nekohasekai.sfa.xposed.XposedInit -io.nekohasekai.sfa.xposed.XposedInit101 diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop index ec342523f..c3975fedc 100644 --- a/app/src/main/resources/META-INF/xposed/module.prop +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -1,3 +1,3 @@ -minApiVersion=100 +minApiVersion=101 targetApiVersion=101 staticScope=true diff --git a/settings.gradle.kts b/settings.gradle.kts index 4044430a2..957a04641 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,5 +16,3 @@ dependencyResolutionManagement { } rootProject.name = "sing-box" include(":app") -include(":libxposed-api") -project(":libxposed-api").projectDir = file("third_party/libxposed-api") diff --git a/third_party/libxposed-api/LICENSE b/third_party/libxposed-api/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/third_party/libxposed-api/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/third_party/libxposed-api/build.gradle.kts b/third_party/libxposed-api/build.gradle.kts deleted file mode 100644 index ce3b9590e..000000000 --- a/third_party/libxposed-api/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - id("com.android.library") -} - -android { - namespace = "io.github.libxposed.api" - compileSdk = 36 - - defaultConfig { - minSdk = 21 - } - - buildFeatures { - androidResources = false - buildConfig = false - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} - -dependencies { - compileOnly("androidx.annotation:annotation:1.9.1") -} diff --git a/third_party/libxposed-api/src/main/AndroidManifest.xml b/third_party/libxposed-api/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee00d..000000000 --- a/third_party/libxposed-api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java deleted file mode 100644 index 3c4ac8651..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterface.java +++ /dev/null @@ -1,525 +0,0 @@ -package io.github.libxposed.api; - -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.os.ParcelFileDescriptor; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Member; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; - -import io.github.libxposed.api.errors.HookFailedError; -import io.github.libxposed.api.utils.DexParser; - -/** - * Xposed interface for modules to operate on application processes. - */ -@SuppressWarnings("unused") -public interface XposedInterface { - /** - * SDK API version. - */ - int API = 100; - - /** - * Indicates that the framework is running as root. - */ - int FRAMEWORK_PRIVILEGE_ROOT = 0; - /** - * Indicates that the framework is running in a container with a fake system_server. - */ - int FRAMEWORK_PRIVILEGE_CONTAINER = 1; - /** - * Indicates that the framework is running as a different app, which may have at most shell permission. - */ - int FRAMEWORK_PRIVILEGE_APP = 2; - /** - * Indicates that the framework is embedded in the hooked app, - * which means {@link #getRemotePreferences} will be null and remote file is unsupported. - */ - int FRAMEWORK_PRIVILEGE_EMBEDDED = 3; - - /** - * The default hook priority. - */ - int PRIORITY_DEFAULT = 50; - /** - * Execute the hook callback late. - */ - int PRIORITY_LOWEST = -10000; - /** - * Execute the hook callback early. - */ - int PRIORITY_HIGHEST = 10000; - - /** - * Contextual interface for before invocation callbacks. - */ - interface BeforeHookCallback { - /** - * Gets the method / constructor to be hooked. - */ - @NonNull - Member getMember(); - - /** - * Gets the {@code this} object, or {@code null} if the method is static. - */ - @Nullable - Object getThisObject(); - - /** - * Gets the arguments passed to the method / constructor. You can modify the arguments. - */ - @NonNull - Object[] getArgs(); - - /** - * Sets the return value of the method and skip the invocation. If the procedure is a constructor, - * the {@code result} param will be ignored. - * Note that the after invocation callback will still be called. - * - * @param result The return value - */ - void returnAndSkip(@Nullable Object result); - - /** - * Throw an exception from the method / constructor and skip the invocation. - * Note that the after invocation callback will still be called. - * - * @param throwable The exception to be thrown - */ - void throwAndSkip(@Nullable Throwable throwable); - } - - /** - * Contextual interface for after invocation callbacks. - */ - interface AfterHookCallback { - /** - * Gets the method / constructor to be hooked. - */ - @NonNull - Member getMember(); - - /** - * Gets the {@code this} object, or {@code null} if the method is static. - */ - @Nullable - Object getThisObject(); - - /** - * Gets all arguments passed to the method / constructor. - */ - @NonNull - Object[] getArgs(); - - /** - * Gets the return value of the method or the before invocation callback. If the procedure is a - * constructor, a void method or an exception was thrown, the return value will be {@code null}. - */ - @Nullable - Object getResult(); - - /** - * Gets the exception thrown by the method / constructor or the before invocation callback. If the - * procedure call was successful, the return value will be {@code null}. - */ - @Nullable - Throwable getThrowable(); - - /** - * Gets whether the invocation was skipped by the before invocation callback. - */ - boolean isSkipped(); - - /** - * Sets the return value of the method and skip the invocation. If the procedure is a constructor, - * the {@code result} param will be ignored. - * - * @param result The return value - */ - void setResult(@Nullable Object result); - - /** - * Sets the exception thrown by the method / constructor. - * - * @param throwable The exception to be thrown. - */ - void setThrowable(@Nullable Throwable throwable); - } - - /** - * Interface for method / constructor hooking. Xposed modules should define their own hooker class - * and implement this interface. Normally, a hooker class corresponds to a method / constructor, but - * there could also be a single hooker class for all of them. By this way you can implement an interface - * like the old API. - * - *

- * Classes implementing this interface should should provide two public static methods named - * before and after for before invocation and after invocation respectively. - *

- * - *

- * The before invocation method should have the following signature:
- * Param {@code callback}: The {@link BeforeHookCallback} of the procedure call.
- * Return value: If you want to save contextual information of one procedure call between the before - * and after callback, it could be a self-defined class, otherwise it should be {@code void}. - *

- * - *

- * The after invocation method should have the following signature:
- * Param {@code callback}: The {@link AfterHookCallback} of the procedure call.
- * Param {@code context} (optional): The contextual object returned by the before invocation. - *

- * - *

Example usage:

- * - *
{@code
-     *   public class ExampleHooker implements Hooker {
-     *
-     *       public static void before(@NonNull BeforeHookCallback callback) {
-     *           // Pre-hooking logic goes here
-     *       }
-     *
-     *       public static void after(@NonNull AfterHookCallback callback) {
-     *           // Post-hooking logic goes here
-     *       }
-     *   }
-     *
-     *   public class ExampleHookerWithContext implements Hooker {
-     *
-     *       public static MyContext before(@NonNull BeforeHookCallback callback) {
-     *           // Pre-hooking logic goes here
-     *           return new MyContext();
-     *       }
-     *
-     *       public static void after(@NonNull AfterHookCallback callback, MyContext context) {
-     *           // Post-hooking logic goes here
-     *       }
-     *   }
-     * }
- */ - interface Hooker { - } - - /** - * Interface for canceling a hook. - * - * @param {@link Method} or {@link Constructor} - */ - interface MethodUnhooker { - /** - * Gets the method or constructor being hooked. - */ - @NonNull - T getOrigin(); - - /** - * Cancels the hook. The behavior of calling this method multiple times is undefined. - */ - void unhook(); - } - - /** - * Gets the Xposed framework name of current implementation. - * - * @return Framework name - */ - @NonNull - String getFrameworkName(); - - /** - * Gets the Xposed framework version of current implementation. - * - * @return Framework version - */ - @NonNull - String getFrameworkVersion(); - - /** - * Gets the Xposed framework version code of current implementation. - * - * @return Framework version code - */ - long getFrameworkVersionCode(); - - /** - * Gets the Xposed framework privilege of current implementation. - * - * @return Framework privilege - */ - int getFrameworkPrivilege(); - - /** - * Hook a method with default priority. - * - * @param origin The method to be hooked - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, - * or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker hook(@NonNull Method origin, @NonNull Class hooker); - - /** - * Hook the static initializer of a class with default priority. - *

- * Note: If the class is initialized, the hook will never be called. - *

- * - * @param origin The class to be hooked - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if class has no static initializer or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker> hookClassInitializer(@NonNull Class origin, @NonNull Class hooker); - - /** - * Hook the static initializer of a class with specified priority. - *

- * Note: If the class is initialized, the hook will never be called. - *

- * - * @param origin The class to be hooked - * @param priority The hook priority - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if class has no static initializer or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker> hookClassInitializer(@NonNull Class origin, int priority, @NonNull Class hooker); - - /** - * Hook a method with specified priority. - * - * @param origin The method to be hooked - * @param priority The hook priority - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, - * or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker hook(@NonNull Method origin, int priority, @NonNull Class hooker); - - /** - * Hook a constructor with default priority. - * - * @param The type of the constructor - * @param origin The constructor to be hooked - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, - * or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker> hook(@NonNull Constructor origin, @NonNull Class hooker); - - /** - * Hook a constructor with specified priority. - * - * @param The type of the constructor - * @param origin The constructor to be hooked - * @param priority The hook priority - * @param hooker The hooker class - * @return Unhooker for canceling the hook - * @throws IllegalArgumentException if origin is abstract, framework internal or {@link Method#invoke}, - * or hooker is invalid - * @throws HookFailedError if hook fails due to framework internal error - */ - @NonNull - MethodUnhooker> hook(@NonNull Constructor origin, int priority, @NonNull Class hooker); - - /** - * Deoptimizes a method in case hooked callee is not called because of inline. - * - *

By deoptimizing the method, the method will back all callee without inlining. - * For example, when a short hooked method B is invoked by method A, the callback to B is not invoked - * after hooking, which may mean A has inlined B inside its method body. To force A to call the hooked B, - * you can deoptimize A and then your hook can take effect.

- * - *

Generally, you need to find all the callers of your hooked callee and that can be hardly achieve - * (but you can still search all callers by using {@link DexParser}). Use this method if you are sure - * the deoptimized callers are all you need. Otherwise, it would be better to change the hook point or - * to deoptimize the whole app manually (by simply reinstalling the app without uninstall).

- * - * @param method The method to deoptimize - * @return Indicate whether the deoptimizing succeed or not - */ - boolean deoptimize(@NonNull Method method); - - /** - * Deoptimizes a constructor in case hooked callee is not called because of inline. - * - * @param The type of the constructor - * @param constructor The constructor to deoptimize - * @return Indicate whether the deoptimizing succeed or not - * @see #deoptimize(Method) - */ - boolean deoptimize(@NonNull Constructor constructor); - - /** - * Basically the same as {@link Method#invoke(Object, Object...)}, but calls the original method - * as it was before the interception by Xposed. - * - * @param method The method to be called - * @param thisObject For non-static calls, the {@code this} pointer, otherwise {@code null} - * @param args The arguments used for the method call - * @return The result returned from the invoked method - * @see Method#invoke(Object, Object...) - */ - @Nullable - Object invokeOrigin(@NonNull Method method, @Nullable Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; - - /** - * Basically the same as {@link Constructor#newInstance(Object...)}, but calls the original constructor - * as it was before the interception by Xposed. - * - * @param constructor The constructor to create and initialize a new instance - * @param thisObject The instance to be constructed - * @param args The arguments used for the construction - * @param The type of the instance - * @see Constructor#newInstance(Object...) - */ - void invokeOrigin(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; - - /** - * Invokes a special (non-virtual) method on a given object instance, similar to the functionality of - * {@code CallNonVirtualMethod} in JNI, which invokes an instance (nonstatic) method on a Java - * object. This method is useful when you need to call a specific method on an object, bypassing any - * overridden methods in subclasses and directly invoking the method defined in the specified class. - * - *

This method is useful when you need to call {@code super.xxx()} in a hooked constructor.

- * - * @param method The method to be called - * @param thisObject For non-static calls, the {@code this} pointer, otherwise {@code null} - * @param args The arguments used for the method call - * @return The result returned from the invoked method - * @see Method#invoke(Object, Object...) - */ - @Nullable - Object invokeSpecial(@NonNull Method method, @NonNull Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; - - /** - * Invokes a special (non-virtual) method on a given object instance, similar to the functionality of - * {@code CallNonVirtualMethod} in JNI, which invokes an instance (nonstatic) method on a Java - * object. This method is useful when you need to call a specific method on an object, bypassing any - * overridden methods in subclasses and directly invoking the method defined in the specified class. - * - *

This method is useful when you need to call {@code super.xxx()} in a hooked constructor.

- * - * @param constructor The constructor to create and initialize a new instance - * @param thisObject The instance to be constructed - * @param args The arguments used for the construction - * @see Constructor#newInstance(Object...) - */ - void invokeSpecial(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException; - - /** - * Basically the same as {@link Constructor#newInstance(Object...)}, but calls the original constructor - * as it was before the interception by Xposed. - * - * @param The type of the constructor - * @param constructor The constructor to create and initialize a new instance - * @param args The arguments used for the construction - * @return The instance created and initialized by the constructor - * @see Constructor#newInstance(Object...) - */ - @NonNull - T newInstanceOrigin(@NonNull Constructor constructor, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException; - - /** - * Creates a new instance of the given subclass, but initialize it with a parent constructor. This could - * leave the object in an invalid state, where the subclass constructor are not called and the fields - * of the subclass are not initialized. - * - *

This method is useful when you need to initialize some fields in the subclass by yourself.

- * - * @param The type of the parent constructor - * @param The type of the subclass - * @param constructor The parent constructor to initialize a new instance - * @param subClass The subclass to create a new instance - * @param args The arguments used for the construction - * @return The instance of subclass initialized by the constructor - * @see Constructor#newInstance(Object...) - */ - @NonNull - U newInstanceSpecial(@NonNull Constructor constructor, @NonNull Class subClass, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException; - - /** - * Writes a message to the Xposed log. - * - * @param message The log message - */ - void log(@NonNull String message); - - /** - * Writes a message with a stack trace to the Xposed log. - * - * @param message The log message - * @param throwable The Throwable object for the stack trace - */ - void log(@NonNull String message, @NonNull Throwable throwable); - - /** - * Parse a dex file in memory. - * - * @param dexData The content of the dex file - * @param includeAnnotations Whether to include annotations - * @return The {@link DexParser} of the dex file - * @throws IOException if the dex file is invalid - */ - @Nullable - DexParser parseDex(@NonNull ByteBuffer dexData, boolean includeAnnotations) throws IOException; - - /** - * Gets the application info of the module. - */ - @NonNull - ApplicationInfo getApplicationInfo(); - - /** - * Gets remote preferences stored in Xposed framework. Note that those are read-only in hooked apps. - * - * @param group Group name - * @return The preferences - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - SharedPreferences getRemotePreferences(@NonNull String group); - - /** - * List all files in the module's shared data directory. - * - * @return The file list - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - String[] listRemoteFiles(); - - /** - * Open a file in the module's shared data directory. The file is opened in read-only mode. - * - * @param name File name, must not contain path separators and . or .. - * @return The file descriptor - * @throws FileNotFoundException If the file does not exist or the path is forbidden - * @throws UnsupportedOperationException If the framework is embedded - */ - @NonNull - ParcelFileDescriptor openRemoteFile(@NonNull String name) throws FileNotFoundException; -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java deleted file mode 100644 index 71eba22e1..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java +++ /dev/null @@ -1,181 +0,0 @@ -package io.github.libxposed.api; - -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.os.ParcelFileDescriptor; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; - -import io.github.libxposed.api.utils.DexParser; - -/** - * Wrap of {@link XposedInterface} used by the modules for the purpose of shielding framework implementation details. - */ -public class XposedInterfaceWrapper implements XposedInterface { - - private volatile XposedInterface mBase; - - public XposedInterfaceWrapper() { - } - - public XposedInterfaceWrapper(@NonNull XposedInterface base) { - mBase = base; - } - - public final void attachFramework(@NonNull XposedInterface base) { - if (mBase != null) { - throw new IllegalStateException("Framework already attached"); - } - mBase = base; - } - - @NonNull - @Override - public final String getFrameworkName() { - return mBase.getFrameworkName(); - } - - @NonNull - @Override - public final String getFrameworkVersion() { - return mBase.getFrameworkVersion(); - } - - @Override - public final long getFrameworkVersionCode() { - return mBase.getFrameworkVersionCode(); - } - - @Override - public final int getFrameworkPrivilege() { - return mBase.getFrameworkPrivilege(); - } - - @NonNull - @Override - public final MethodUnhooker hook(@NonNull Method origin, @NonNull Class hooker) { - return mBase.hook(origin, hooker); - } - - @NonNull - @Override - public MethodUnhooker> hookClassInitializer(@NonNull Class origin, @NonNull Class hooker) { - return mBase.hookClassInitializer(origin, hooker); - } - - @NonNull - @Override - public MethodUnhooker> hookClassInitializer(@NonNull Class origin, int priority, @NonNull Class hooker) { - return mBase.hookClassInitializer(origin, priority, hooker); - } - - @NonNull - @Override - public final MethodUnhooker hook(@NonNull Method origin, int priority, @NonNull Class hooker) { - return mBase.hook(origin, priority, hooker); - } - - @NonNull - @Override - public final MethodUnhooker> hook(@NonNull Constructor origin, @NonNull Class hooker) { - return mBase.hook(origin, hooker); - } - - @NonNull - @Override - public final MethodUnhooker> hook(@NonNull Constructor origin, int priority, @NonNull Class hooker) { - return mBase.hook(origin, priority, hooker); - } - - @Override - public final boolean deoptimize(@NonNull Method method) { - return mBase.deoptimize(method); - } - - @Override - public final boolean deoptimize(@NonNull Constructor constructor) { - return mBase.deoptimize(constructor); - } - - @Nullable - @Override - public final Object invokeOrigin(@NonNull Method method, @Nullable Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { - return mBase.invokeOrigin(method, thisObject, args); - } - - @Override - public void invokeOrigin(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { - mBase.invokeOrigin(constructor, thisObject, args); - } - - @Nullable - @Override - public final Object invokeSpecial(@NonNull Method method, @NonNull Object thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { - return mBase.invokeSpecial(method, thisObject, args); - } - - @Override - public void invokeSpecial(@NonNull Constructor constructor, @NonNull T thisObject, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException { - mBase.invokeSpecial(constructor, thisObject, args); - } - - @NonNull - @Override - public final T newInstanceOrigin(@NonNull Constructor constructor, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException { - return mBase.newInstanceOrigin(constructor, args); - } - - @NonNull - @Override - public final U newInstanceSpecial(@NonNull Constructor constructor, @NonNull Class subClass, Object... args) throws InvocationTargetException, IllegalArgumentException, IllegalAccessException, InstantiationException { - return mBase.newInstanceSpecial(constructor, subClass, args); - } - - @Override - public final void log(@NonNull String message) { - mBase.log(message); - } - - @Override - public final void log(@NonNull String message, @NonNull Throwable throwable) { - mBase.log(message, throwable); - } - - @Nullable - @Override - public final DexParser parseDex(@NonNull ByteBuffer dexData, boolean includeAnnotations) throws IOException { - return mBase.parseDex(dexData, includeAnnotations); - } - - @NonNull - @Override - public SharedPreferences getRemotePreferences(@NonNull String name) { - return mBase.getRemotePreferences(name); - } - - @NonNull - @Override - public ApplicationInfo getApplicationInfo() { - return mBase.getApplicationInfo(); - } - - @NonNull - @Override - public String[] listRemoteFiles() { - return mBase.listRemoteFiles(); - } - - @NonNull - @Override - public ParcelFileDescriptor openRemoteFile(@NonNull String name) throws FileNotFoundException { - return mBase.openRemoteFile(name); - } -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java deleted file mode 100644 index 0c8c75598..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.libxposed.api; - -import androidx.annotation.NonNull; - -/** - * Super class which all Xposed module entry classes should extend.
- * Entry classes will be instantiated exactly once for each process. - */ -@SuppressWarnings("unused") -public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { - /** - * No-arg constructor for API 101 contract: the framework instantiates the module via - * {@code Class.getDeclaredConstructor()}, then calls {@link #attachFramework}. - */ - public XposedModule() { - super(); - } - - /** - * Two-arg constructor for API 100 contract: the framework instantiates the module via - * {@code (XposedInterface, ModuleLoadedParam)} and attaches the framework base inline. - */ - public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) { - super(base); - } -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java deleted file mode 100644 index 953edac2e..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java +++ /dev/null @@ -1,147 +0,0 @@ -package io.github.libxposed.api; - -import android.app.AppComponentFactory; -import android.content.pm.ApplicationInfo; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -/** - * Interface for module initialization. - */ -@SuppressWarnings("unused") -public interface XposedModuleInterface { - /** - * Wraps information about the process in which the module is loaded. - */ - interface ModuleLoadedParam { - /** - * Gets information about whether the module is running in system server. - * - * @return {@code true} if the module is running in system server - */ - boolean isSystemServer(); - - /** - * Gets the process name. - * - * @return The process name - */ - @NonNull - String getProcessName(); - } - - /** - * Wraps information about system server. API 100 flavor. - */ - interface SystemServerLoadedParam { - /** - * Gets the class loader of system server. - * - * @return The class loader - */ - @NonNull - ClassLoader getClassLoader(); - } - - /** - * Wraps information about system server. API 101 flavor. - */ - interface SystemServerStartingParam { - @NonNull - ClassLoader getClassLoader(); - } - - /** - * Wraps information about a package whose classloader is ready. API 101. - */ - interface PackageReadyParam extends PackageLoadedParam { - @NonNull - ClassLoader getClassLoader(); - - @RequiresApi(Build.VERSION_CODES.P) - @NonNull - AppComponentFactory getAppComponentFactory(); - } - - /** - * Wraps information about the package being loaded. - */ - interface PackageLoadedParam { - /** - * Gets the package name of the package being loaded. - * - * @return The package name. - */ - @NonNull - String getPackageName(); - - /** - * Gets the {@link ApplicationInfo} of the package being loaded. - * - * @return The ApplicationInfo. - */ - @NonNull - ApplicationInfo getApplicationInfo(); - - /** - * Gets default class loader. - * - * @return the default class loader - */ - @RequiresApi(Build.VERSION_CODES.Q) - @NonNull - ClassLoader getDefaultClassLoader(); - - /** - * Gets the class loader of the package being loaded. - * - * @return The class loader. - */ - @NonNull - ClassLoader getClassLoader(); - - /** - * Gets information about whether is this package the first and main package of the app process. - * - * @return {@code true} if this is the first package. - */ - boolean isFirstPackage(); - } - - /** - * Gets notified when a package is loaded into the app process.
- * This callback could be invoked multiple times for the same process on each package. - * - * @param param Information about the package being loaded - */ - default void onPackageLoaded(@NonNull PackageLoadedParam param) { - } - - /** - * Gets notified when the system server is loaded. API 100. - * - * @param param Information about system server - */ - default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) { - } - - /** - * API 101: invoked once per process after the module instance is attached. - */ - default void onModuleLoaded(@NonNull ModuleLoadedParam param) { - } - - /** - * API 101: invoked when a package's classloader is ready. - */ - default void onPackageReady(@NonNull PackageReadyParam param) { - } - - /** - * API 101: replaces {@link #onSystemServerLoaded(SystemServerLoadedParam)}. - */ - default void onSystemServerStarting(@NonNull SystemServerStartingParam param) { - } -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java deleted file mode 100644 index 0eb4b052d..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/HookFailedError.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.libxposed.api.errors; - -/** - * Thrown to indicate that a hook failed due to framework internal error. - */ -@SuppressWarnings("unused") -public class HookFailedError extends XposedFrameworkError { - - public HookFailedError(String message) { - super(message); - } - - public HookFailedError(String message, Throwable cause) { - super(message, cause); - } - - public HookFailedError(Throwable cause) { - super(cause); - } -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java deleted file mode 100644 index 0b3bba005..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/errors/XposedFrameworkError.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.github.libxposed.api.errors; - -/** - * Thrown to indicate that the Xposed framework function is broken. - */ -public class XposedFrameworkError extends Error { - - public XposedFrameworkError(String message) { - super(message); - } - - public XposedFrameworkError(String message, Throwable cause) { - super(message, cause); - } - - public XposedFrameworkError(Throwable cause) { - super(cause); - } -} diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java deleted file mode 100644 index 00a5f439f..000000000 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/utils/DexParser.java +++ /dev/null @@ -1,376 +0,0 @@ -package io.github.libxposed.api.utils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.Closeable; - -/** - * Xposed interface for parsing dex files. - */ -@SuppressWarnings("unused") -public interface DexParser extends Closeable { - /** - * The constant NO_INDEX. - */ - int NO_INDEX = 0xffffffff; - - /** - * The interface Array. - */ - interface Array { - /** - * Get values value [ ]. - * - * @return the value [ ] - */ - @NonNull - Value[] getValues(); - } - - /** - * The interface Annotation. - */ - interface Annotation { - /** - * Gets visibility. - * - * @return the visibility - */ - int getVisibility(); - - /** - * Gets type. - * - * @return the type - */ - @NonNull - TypeId getType(); - - /** - * Get elements element [ ]. - * - * @return the element [ ] - */ - @NonNull - Element[] getElements(); - } - - /** - * The interface Value. - */ - interface Value { - - /** - * Get value byte [ ]. - * - * @return the byte [ ] - */ - @Nullable - byte[] getValue(); - - /** - * Gets value type. - * - * @return the value type - */ - int getValueType(); - } - - /** - * The interface Element. - */ - interface Element extends Value { - /** - * Gets name. - * - * @return the name - */ - @NonNull - StringId getName(); - } - /** - * The interface Id. - */ - interface Id extends Comparable { - /** - * Gets id. - * - * @return the id - */ - int getId(); - } - - /** - * The interface Type id. - */ - interface TypeId extends Id { - /** - * Gets descriptor. - * - * @return the descriptor - */ - @NonNull - StringId getDescriptor(); - } - - - /** - * The interface String id. - */ - interface StringId extends Id { - /** - * Gets string. - * - * @return the string - */ - @NonNull - String getString(); - } - - /** - * The interface Field id. - */ - interface FieldId extends Id { - /** - * Gets type. - * - * @return the type - */ - @NonNull - TypeId getType(); - - /** - * Gets declaring class. - * - * @return the declaring class - */ - @NonNull - TypeId getDeclaringClass(); - - /** - * Gets name. - * - * @return the name - */ - @NonNull - StringId getName(); - } - - /** - * The interface Method id. - */ - interface MethodId extends Id { - /** - * Gets declaring class. - * - * @return the declaring class - */ - @NonNull - TypeId getDeclaringClass(); - - /** - * Gets prototype. - * - * @return the prototype - */ - @NonNull - ProtoId getPrototype(); - - /** - * Gets name. - * - * @return the name - */ - @NonNull - StringId getName(); - } - - /** - * The interface Proto id. - */ - interface ProtoId extends Id { - /** - * Gets shorty. - * - * @return the shorty - */ - @NonNull - StringId getShorty(); - - /** - * Gets return type. - * - * @return the return type - */ - @NonNull - TypeId getReturnType(); - - /** - * Get parameters type id [ ]. - * - * @return the type id [ ] - */ - @Nullable - TypeId[] getParameters(); - } - - /** - * Get string id string id [ ]. - * - * @return the string id [ ] - */ - @NonNull - StringId[] getStringId(); - - /** - * Get type id type id [ ]. - * - * @return the type id [ ] - */ - @NonNull - TypeId[] getTypeId(); - - /** - * Get field id field id [ ]. - * - * @return the field id [ ] - */ - @NonNull - FieldId[] getFieldId(); - - /** - * Get method id method id [ ]. - * - * @return the method id [ ] - */ - @NonNull - MethodId[] getMethodId(); - - /** - * Get proto id proto id [ ]. - * - * @return the proto id [ ] - */ - @NonNull - ProtoId[] getProtoId(); - - /** - * Get annotations annotation [ ]. - * - * @return the annotation [ ] - */ - @NonNull - Annotation[] getAnnotations(); - - /** - * Get arrays array [ ]. - * - * @return the array [ ] - */ - @NonNull - Array[] getArrays(); - - /** - * The interface Early stop visitor. - */ - interface EarlyStopVisitor { - /** - * Stop boolean. - * - * @return the boolean - */ - boolean stop(); - } - - /** - * The interface Member visitor. - */ - interface MemberVisitor extends EarlyStopVisitor { - } - - /** - * The interface Class visitor. - */ - interface ClassVisitor extends EarlyStopVisitor { - /** - * Visit member visitor. - * - * @param clazz the clazz - * @param accessFlags the access flags - * @param superClass the super class - * @param interfaces the interfaces - * @param sourceFile the source file - * @param staticFields the static fields - * @param staticFieldsAccessFlags the static fields access flags - * @param instanceFields the instance fields - * @param instanceFieldsAccessFlags the instance fields access flags - * @param directMethods the direct methods - * @param directMethodsAccessFlags the direct methods access flags - * @param virtualMethods the virtual methods - * @param virtualMethodsAccessFlags the virtual methods access flags - * @param annotations the annotations - * @return the member visitor - */ - @Nullable - MemberVisitor visit(int clazz, int accessFlags, int superClass, @NonNull int[] interfaces, int sourceFile, @NonNull int[] staticFields, @NonNull int[] staticFieldsAccessFlags, @NonNull int[] instanceFields, @NonNull int[] instanceFieldsAccessFlags, @NonNull int[] directMethods, @NonNull int[] directMethodsAccessFlags, @NonNull int[] virtualMethods, @NonNull int[] virtualMethodsAccessFlags, @NonNull int[] annotations); - } - - /** - * The interface Field visitor. - */ - interface FieldVisitor extends MemberVisitor { - /** - * Visit. - * - * @param field the field - * @param accessFlags the access flags - * @param annotations the annotations - */ - void visit(int field, int accessFlags, @NonNull int[] annotations); - } - - /** - * The interface Method visitor. - */ - interface MethodVisitor extends MemberVisitor { - /** - * Visit method body visitor. - * - * @param method the method - * @param accessFlags the access flags - * @param hasBody the has body - * @param annotations the annotations - * @param parameterAnnotations the parameter annotations - * @return the method body visitor - */ - @Nullable - MethodBodyVisitor visit(int method, int accessFlags, boolean hasBody, @NonNull int[] annotations, @NonNull int[] parameterAnnotations); - } - - /** - * The interface Method body visitor. - */ - interface MethodBodyVisitor { - /** - * Visit. - * - * @param method the method - * @param accessFlags the access flags - * @param referredStrings the referred strings - * @param invokedMethods the invoked methods - * @param accessedFields the accessed fields - * @param assignedFields the assigned fields - * @param opcodes the opcodes - */ - void visit(int method, int accessFlags, @NonNull int[] referredStrings, @NonNull int[] invokedMethods, @NonNull int[] accessedFields, @NonNull int[] assignedFields, @NonNull byte[] opcodes); - } - - /** - * Visit defined classes. - * - * @param visitor the visitor - * @throws IllegalStateException the illegal state exception - */ - void visitDefinedClasses(@NonNull ClassVisitor visitor) throws IllegalStateException; -} diff --git a/version.properties b/version.properties index 0d0ef43d9..d9647967c 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=662 -VERSION_NAME=1.13.11 +VERSION_CODE=666 +VERSION_NAME=1.14.0-alpha.20 GO_VERSION=go1.25.9