diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt index 98ce882..d90e2c7 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.vendor +import io.nekohasekai.libbox.HTTPResponseWriteToProgressHandler import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.update.UpdateState @@ -27,7 +28,15 @@ class ApkDownloader : Closeable { request.setURL(url) val response = request.execute() - response.writeTo(apkFile.absolutePath) + response.writeToWithProgress( + apkFile.absolutePath, + object : HTTPResponseWriteToProgressHandler { + override fun update(progress: Long, total: Long) { + UpdateState.downloadProgress.value = + if (total > 0) progress.toFloat() / total.toFloat() else null + } + }, + ) if (!apkFile.exists() || apkFile.length() == 0L) { throw Exception("Download failed: empty file") diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt index d241a2c..15f8ba7 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -86,9 +86,7 @@ class GitHubUpdateChecker : Closeable { } } - private fun isNewerThanCurrent(versionName: String): Boolean { - return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) - } + private fun isNewerThanCurrent(versionName: String): Boolean = Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { if (Libbox.compareSemver(version.versionName, other.versionName)) { diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt index 7b14573..a4ee0af 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -11,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate import java.util.concurrent.TimeUnit class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { @@ -59,8 +61,13 @@ class UpdateWorker(private val appContext: Context, params: WorkerParameters) : Log.d(TAG, "Checking for updates...") return try { - val track = UpdateTrack.fromString(Settings.updateTrack) - val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } + val updateInfo = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(appContext) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { it.checkUpdate(track) } + } + } if (updateInfo == null) { Log.d(TAG, "No update available") 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 0000000..a2ed3cf --- /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 fc58161..382c192 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 0000000..8c3cf81 --- /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 02b2467..b5f490a 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 013406c..6331123 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 1761e65..da211c8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -162,7 +162,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl android.Manifest.permission.ACCESS_BACKGROUND_LOCATION } if (!service.hasPermission(wifiPermission)) { - closeService() stopAndAlert(Alert.RequestLocationPermission) return } @@ -243,7 +242,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl android.Manifest.permission.ACCESS_BACKGROUND_LOCATION } if (!service.hasPermission(wifiPermission)) { - closeService() stopAndAlert(Alert.RequestLocationPermission) return } @@ -311,6 +309,16 @@ class BoxService(private val service: Service, private val platformInterface: Pl private suspend fun stopAndAlert(type: Alert, message: String? = null) { Settings.startedByUser = false + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + DefaultNetworkMonitor.stop() + if (::commandServer.isInitialized) { + closeService() + commandServer.close() + } withContext(Dispatchers.Main) { if (receiverRegistered) { service.unregisterReceiver(receiver) @@ -321,6 +329,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl callback.onServiceAlert(type.ordinal, message) } status.value = Status.Stopped + service.stopSelf() } } @@ -408,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } + override fun triggerNativeCrash() { + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + } + override fun writeDebugMessage(message: String?) { Log.d("sing-box", message!!) } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt new file mode 100644 index 0000000..cb2a27b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -0,0 +1,251 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class CrashReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class CrashReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + GO_LOG, + JVM_LOG, + CONFIG, + } +} + +object CrashReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val GO_LOG_FILE_NAME = "go.log" + private const val JVM_LOG_FILE_NAME = "jvm.log" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val READ_MARKER_FILE_NAME = ".read" + private const val CRASH_REPORTS_DIR_NAME = "crash_reports" + private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log" + private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + private lateinit var baseDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File, baseDir: File) { + this.workingDir = workingDir + this.baseDir = baseDir + archivePendingJvmCrashReport() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + writePendingJvmCrashReport(thread, throwable) + previous?.uncaughtException(thread, throwable) + } + } + + private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) { + try { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString()) + val metadata = JSONObject().apply { + put("source", "Application") + put("crashedAt", formatTimestampISO8601(Date())) + put("exceptionName", throwable.javaClass.name) + put("exceptionReason", throwable.message ?: "") + put("processName", Application.application.packageName) + put("appVersion", BuildConfig.VERSION_CODE.toString()) + put("appMarketingVersion", BuildConfig.VERSION_NAME) + runCatching { + put("coreVersion", Libbox.version()) + put("goVersion", Libbox.goVersion()) + } + } + File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString()) + } catch (_: Throwable) { + } + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanCrashReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun archivePendingJvmCrashReport() { + val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME) + val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME) + val configFile = File(baseDir, CONFIG_FILE_NAME) + if (!crashFile.exists()) return + val content = crashFile.readText().trim() + if (content.isEmpty()) { + crashFile.delete() + metadataFile.delete() + configFile.delete() + return + } + val crashDate = Date(crashFile.lastModified()) + val reportDir = nextAvailableReportDir(crashDate) + reportDir.mkdirs() + crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true) + crashFile.delete() + if (metadataFile.exists()) { + metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true) + metadataFile.delete() + } + if (configFile.exists()) { + val configContent = runCatching { configFile.readText() }.getOrNull()?.trim() + if (!configContent.isNullOrEmpty()) { + configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true) + } + configFile.delete() + } + } + + private fun scanCrashReports(): List { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + if (!crashReportsDir.isDirectory) return emptyList() + val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + CrashReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: CrashReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val goLogFile = File(report.directory, GO_LOG_FILE_NAME) + if (goLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile)) + } + val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME) + if (jvmLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile)) + } + return files + } + + fun loadFileContent(file: CrashReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == CrashReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: CrashReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun nextAvailableReportDir(date: Date): File { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + val baseName = timestampFormat.format(date) + var index = 0 + while (true) { + val suffix = if (index == 0) "" else "-$index" + val dir = File(crashReportsDir, baseName + suffix) + if (!dir.exists()) return dir + index++ + } + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } + + private fun formatTimestampISO8601(date: Date): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return format.format(date) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt index a45c8d2..b304a70 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt @@ -13,11 +13,13 @@ import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream object DebugInfoExporter { private const val TAG = "DebugInfoExporter" + private const val BUFFER_SIZE = 128 * 1024 fun export(context: Context, outputPath: String, packageName: String): String { Log.i(TAG, "export start: output=$outputPath, package=$packageName") @@ -94,43 +96,27 @@ object DebugInfoExporter { private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList): Int { var count = 0 - val roots = - listOf( - File("/system/framework"), - File("/system_ext/framework"), - File("/product/framework"), - File("/vendor/framework"), - ) + val root = File("/system/framework") + if (!root.isDirectory) return 0 val targetFiles = setOf("framework.jar", "services.jar") - for (root in roots) { - if (!root.isDirectory) continue - val destPrefix = "framework/${root.name}" - val files = root.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (file.name !in targetFiles) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } + val files = root.listFiles() ?: emptyArray() + for (file in files) { + if (!file.isFile) continue + if (file.name !in targetFiles) continue + if (addFileEntry(zip, file, "framework/${file.name}", warnings, noCompression = true)) { + count++ } } return count } private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList): Int { - var count = 0 - val tetheringApex = File("/apex/com.android.tethering/javalib") - if (!tetheringApex.isDirectory) return 0 - val destPrefix = "framework/apex_com.android.tethering" - val files = tetheringApex.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (!file.name.lowercase(Locale.US).endsWith(".jar")) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } + val file = File("/apex/com.android.tethering/javalib/service-connectivity.jar") + if (!file.isFile) { + warnings.add("missing file: ${file.path}") + return 0 } - return count + return if (addFileEntry(zip, file, "framework/apex_com.android.tethering/service-connectivity.jar", warnings, noCompression = true)) 1 else 0 } private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList, context: Context): Int { @@ -222,16 +208,22 @@ object DebugInfoExporter { return count } - private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList): Boolean { + private fun addFileEntry( + zip: ZipOutputStream, + file: File, + entryName: String, + warnings: MutableList, + noCompression: Boolean = false, + ): Boolean { if (!file.isFile) { warnings.add("missing file: ${file.path}") return false } try { - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) + if (noCompression) zip.setLevel(Deflater.NO_COMPRESSION) + zip.putNextEntry(ZipEntry(entryName)) BufferedInputStream(FileInputStream(file)).use { input -> - val buffer = ByteArray(16 * 1024) + val buffer = ByteArray(BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read <= 0) break @@ -239,9 +231,11 @@ object DebugInfoExporter { } } zip.closeEntry() + if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION) return true } catch (e: Throwable) { warnings.add("zip failed ${file.path}: ${e.message}") + if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION) return false } } @@ -263,11 +257,10 @@ object DebugInfoExporter { command: List, ): CommandResult? = try { val process = ProcessBuilder(command).redirectErrorStream(true).start() - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) + zip.putNextEntry(ZipEntry(entryName)) var bytes = 0L process.inputStream.use { input -> - val buffer = ByteArray(16 * 1024) + val buffer = ByteArray(BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read <= 0) break diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt index 3c02e04..87fc729 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -43,17 +43,20 @@ object DefaultNetworkMonitor { private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { val listener = listener ?: return if (newNetwork != null) { - val interfaceName = - (Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName for (times in 0 until 10) { + val linkProperties = Application.connectivity.getLinkProperties(newNetwork) + if (linkProperties == null) { + Thread.sleep(100) + continue + } var interfaceIndex: Int try { - interfaceIndex = NetworkInterface.getByName(interfaceName).index + interfaceIndex = NetworkInterface.getByName(linkProperties.interfaceName).index } catch (e: Exception) { Thread.sleep(100) continue } - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) + listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, false, false) } } else { listener.updateDefaultInterface("", -1, false, false) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt index 26f0254..e382996 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt @@ -23,8 +23,8 @@ object LocalResolver : LocalDNSTransport { @RequiresApi(Build.VERSION_CODES.Q) override fun exchange(ctx: ExchangeContext, message: ByteArray) { + val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface") return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() suspendCoroutine { continuation -> val signal = CancellationSignal() ctx.onCancel(signal::cancel) @@ -63,8 +63,8 @@ object LocalResolver : LocalDNSTransport { } override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface") return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { suspendCoroutine { continuation -> val signal = CancellationSignal() 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 0000000..97c97ad --- /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 0000000..183b19e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -0,0 +1,165 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class OOMReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class OOMReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + CONFIG, + PROFILE, + } +} + +object OOMReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val CMDLINE_FILE_NAME = "cmdline" + private const val READ_MARKER_FILE_NAME = ".read" + private const val OOM_REPORTS_DIR_NAME = "oom_reports" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File) { + this.workingDir = workingDir + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun scanReports(): List { + val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME) + if (!reportsDir.isDirectory) return emptyList() + val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + OOMReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: OOMReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile)) + } + report.directory.listFiles()?.filter { file -> + file.isFile && + file.name != METADATA_FILE_NAME && + file.name != CONFIG_FILE_NAME && + file.name != CMDLINE_FILE_NAME && + file.name != READ_MARKER_FILE_NAME + }?.sortedBy { it.name }?.forEach { file -> + files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file)) + } + return files + } + + fun loadFileContent(file: OOMReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == OOMReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: OOMReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java index 9840067..60c824f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java @@ -136,7 +136,7 @@ public class ParceledListSlice implements Parcelable { new Parcelable.ClassLoaderCreator() { @Override public ParceledListSlice createFromParcel(Parcel in) { - return new ParceledListSlice(in, null); + return new ParceledListSlice(in, ParceledListSlice.class.getClassLoader()); } @Override 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 fa7cea5..78b3888 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 @@ -58,7 +65,7 @@ interface PlatformInterfaceWrapper : PlatformInterface { val owner = ConnectionOwner() owner.userId = uid owner.userName = packages?.firstOrNull() ?: "" - owner.androidPackageName = packages?.firstOrNull() ?: "" + owner.setAndroidPackageNames(StringArray(packages?.toList()?.iterator() ?: emptyList().iterator())) return owner } catch (e: Exception) { Log.e("PlatformInterface", "getConnectionOwnerUid", e) @@ -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 ab33003..3cdc8f3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -6,6 +6,7 @@ import android.content.ServiceConnection import android.content.pm.PackageInfo import android.os.IBinder import android.os.RemoteException +import androidx.core.content.ContextCompat import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ipc.RootService import io.nekohasekai.sfa.Application @@ -17,7 +18,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.IOException import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException object RootClient { init { @@ -53,6 +56,10 @@ object RootClient { suspend fun bindService(): IRootService = connectionMutex.withLock { service?.let { return it } + if (Shell.isAppGrantedRoot() == false) { + throw IOException("permission denied") + } + return withContext(Dispatchers.Main) { suspendCancellableCoroutine { continuation -> val conn = object : ServiceConnection { @@ -72,7 +79,30 @@ object RootClient { } val intent = Intent(Application.application, RootServer::class.java) - RootService.bind(intent, conn) + val task = RootService.bindOrTask( + intent, + ContextCompat.getMainExecutor(Application.application), + conn, + ) + + if (task == null) { + // Already connected, onServiceConnected will fire + } else { + Shell.EXECUTOR.execute { + try { + val shell = Shell.getShell() + if (shell.isRoot) { + shell.execTask(task) + } else { + continuation.resumeWithException( + IOException("permission denied"), + ) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + } continuation.invokeOnCancellation { RootService.unbind(conn) @@ -103,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 352d159..13b0bcc 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/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index d958184..942ae83 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -7,6 +7,7 @@ import android.net.VpnService import android.os.Build import android.os.IBinder import android.util.Log +import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.TunOptions import io.nekohasekai.sfa.database.Settings @@ -66,6 +67,10 @@ class VPNService : builder.setMetered(false) } + if (Settings.allowBypass) { + builder.allowBypass() + } + val inet4Address = options.inet4Address while (inet4Address.hasNext()) { val address = inet4Address.next() @@ -79,7 +84,12 @@ class VPNService : } if (options.autoRoute) { - builder.addDnsServer(options.dnsServerAddress.value) + if (options.dnsMode.value != Libbox.DNSModeDisabled) { + val dnsServerAddress = options.dnsServerAddress + while (dnsServerAddress.hasNext()) { + builder.addDnsServer(dnsServerAddress.next()) + } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val inet4RouteAddress = options.inet4RouteAddress 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 574422b..80f12bf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.net.VpnService import android.os.Build import android.os.Bundle +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -42,6 +43,7 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar @@ -85,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 @@ -109,10 +114,12 @@ 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 import io.nekohasekai.sfa.compose.topbar.TopBarEntry +import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.ServiceMode import io.nekohasekai.sfa.constant.Status @@ -123,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 @@ -225,6 +233,10 @@ class MainActivity : pendingNavigationRoute.value = "settings/privilege" } val uri = intent.data ?: return + if (intent.action == Action.OPEN_URL) { + launchCustomTab(uri.toString()) + return + } if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { try { val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) @@ -320,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) } @@ -328,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) { @@ -565,10 +658,22 @@ class MainActivity : color = MaterialTheme.colorScheme.error, ) } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) + val progress by UpdateState.downloadProgress + Column { + if (progress != null) { + Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%") + } else { + Text(stringResource(R.string.downloading)) + } + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + LinearProgressIndicator( + progress = { progress!! }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } } @@ -580,6 +685,7 @@ class MainActivity : downloadJob = null showDownloadDialog = false downloadError = null + UpdateState.downloadProgress.value = null }, ) { Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) @@ -596,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 @@ -610,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) { @@ -640,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 @@ -654,6 +770,7 @@ class MainActivity : add(Screen.Connections) } add(Screen.Log) + add(Screen.Tools) add(Screen.Settings) } @@ -661,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) @@ -719,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) } } } @@ -769,6 +870,7 @@ class MainActivity : logViewModel = logViewModel, groupsViewModel = groupsViewModel, connectionsViewModel = connectionsViewModel, + tailscaleStatusViewModel = tailscaleStatusViewModel, modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { @@ -899,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()) { @@ -916,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) } @@ -960,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) } @@ -1088,6 +1209,10 @@ class MainActivity : } } + BackHandler(enabled = selectedConnectionId != null) { + selectedConnectionId = null + } + ModalBottomSheet( onDismissRequest = { showConnectionsSheet = false @@ -1168,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 0000000..7f31617 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt @@ -0,0 +1,16 @@ +package io.nekohasekai.sfa.compose.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.nekohasekai.sfa.constant.Status + +@Composable +fun rememberApplyServiceChangeNotifier( + serviceStatus: Status, +): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) { + { mode -> + if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 6b7467a..70b1f3c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -19,7 +19,12 @@ sealed class UiEvent { object RequestReconnectService : UiEvent() - object RestartToTakeEffect : UiEvent() + data class ApplyServiceChange(val mode: Mode) : UiEvent() { + enum class Mode { + Reload, + Restart, + } + } } /** diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt b/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt index c84759e..ee5953a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt @@ -6,7 +6,7 @@ import io.nekohasekai.libbox.Connection as LibboxConnection import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo @Immutable -data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) { +data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageNames: List) { companion object { fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { if (processInfo == null) return null @@ -15,7 +15,7 @@ data class ProcessInfo(val processId: Long, val userId: Int, val userName: Strin userId = processInfo.userID, userName = processInfo.userName ?: "", processPath = processInfo.processPath ?: "", - packageName = processInfo.packageName ?: "", + packageNames = processInfo.packageNames()?.toList() ?: emptyList(), ) } } @@ -66,7 +66,7 @@ data class Connection( domain.contains(content, ignoreCase = true) || outbound.contains(content, ignoreCase = true) || rule.contains(content, ignoreCase = true) || - processInfo?.packageName?.contains(content, ignoreCase = true) == true + processInfo?.packageNames?.any { it.contains(content, ignoreCase = true) } == true private fun performSearchType(type: String, value: String): Boolean = when (type) { "network" -> network.equals(value, ignoreCase = true) @@ -79,7 +79,7 @@ data class Connection( "rule" -> rule.contains(value, ignoreCase = true) "protocol" -> protocolName.equals(value, ignoreCase = true) "user" -> user.contains(value, ignoreCase = true) - "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true + "package" -> processInfo?.packageNames?.any { it.contains(value, ignoreCase = true) } == true "chain" -> chain.any { it.contains(value, ignoreCase = true) } else -> false } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 27456b9..9ae23cc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Terminal import androidx.compose.ui.graphics.vector.ImageVector import io.nekohasekai.sfa.R @@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I icon = Icons.Default.SwapVert, ) + object Tools : Screen( + route = "tools", + titleRes = R.string.title_tools, + icon = Icons.Default.Terminal, + ) + object Settings : Screen( route = "settings", titleRes = R.string.title_settings, @@ -46,5 +53,6 @@ val bottomNavigationScreens = listOf( Screen.Dashboard, Screen.Log, + Screen.Tools, Screen.Settings, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 2f46d17..6357e5c 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 @@ -28,10 +29,26 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen 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 = { @@ -63,6 +80,7 @@ fun SFANavHost( logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null, + tailscaleStatusViewModel: TailscaleStatusViewModel? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -209,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) } @@ -221,7 +407,17 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - AppSettingsScreen(navController = navController) + AppSettingsScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "settings/fdroid_mirror", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + FDroidMirrorScreen(navController = navController) } composable( @@ -241,7 +437,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ServiceSettingsScreen(navController = navController) + ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -251,7 +447,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ProfileOverrideScreen(navController = navController) + ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -261,7 +457,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PerAppProxyScreen(onBack = { navController.navigateUp() }) + PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } composable( @@ -281,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/configuration/ProfileImportHandler.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt index 0a8e9a5..c89532e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt @@ -241,8 +241,9 @@ class ProfileImportHandler(private val context: Context) { } // Save config file + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText(content.config) typedProfile.path = configFile.path @@ -268,8 +269,9 @@ class ProfileImportHandler(private val context: Context) { } // Create empty config file for remote profile + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText("{}") typedProfile.path = configFile.path @@ -370,8 +372,9 @@ class ProfileImportHandler(private val context: Context) { } // Save the configuration file + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText(jsonContent) typedProfile.path = configFile.path diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt index ef57240..bb7548d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -247,7 +247,7 @@ fun ConnectionDetailsScreen( } connection.processInfo?.let { processInfo -> - if (processInfo.packageName.isNotEmpty() || + if (processInfo.packageNames.isNotEmpty() || processInfo.processPath.isNotEmpty() || processInfo.processId > 0 ) { @@ -282,10 +282,10 @@ fun ConnectionDetailsScreen( monospace = true, ) } - if (processInfo.packageName.isNotEmpty()) { + if (processInfo.packageNames.isNotEmpty()) { DetailRow( label = stringResource(R.string.connection_package_name), - value = processInfo.packageName, + value = processInfo.packageNames.joinToString(", "), monospace = true, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt index 49dc7a0..a525ad5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -82,7 +82,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? { @Composable fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { var showContextMenu by remember { mutableStateOf(false) } - val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() } + val packageName = connection.processInfo?.packageNames?.firstOrNull() val appInfo = packageName?.let { rememberAppInfo(it) } Box(modifier = modifier) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index 943eca0..32f2ed0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -200,7 +200,7 @@ class DashboardViewModel : private fun checkDeprecatedNotes() { viewModelScope.launch(Dispatchers.IO) { - try { + runCatching { // Check if deprecated warnings are disabled if (Settings.disableDeprecatedWarnings) { return@launch @@ -227,8 +227,6 @@ class DashboardViewModel : } } } - } catch (e: Exception) { - sendError(e) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt index c4a8631..c1f50c8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.ui.graphics.lerp import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -52,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt index 31a5daa..240a5c5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt @@ -19,8 +19,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt index 601a51d..ecaf56d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -81,15 +81,19 @@ class LogViewModel : override fun setDefaultLogLevel(level: Int) { val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") - _uiState.update { it.copy(defaultLogLevel = logLevel) } - updateDisplayedLogs() + viewModelScope.launch(Dispatchers.Main) { + _uiState.update { it.copy(defaultLogLevel = logLevel) } + updateDisplayedLogs() + } } override fun clearLogs() { - allLogs.clear() - bufferedLogs.clear() - _uiState.update { it.copy(isPaused = false) } - updateDisplayedLogs() + viewModelScope.launch(Dispatchers.Main) { + allLogs.clear() + bufferedLogs.clear() + _uiState.update { it.copy(isPaused = false) } + updateDisplayedLogs() + } } override fun requestClearLogs() { @@ -104,23 +108,25 @@ class LogViewModel : override fun appendLogs(message: List) { val processedLogs = message.map { processLogEntry(it) } - if (_uiState.value.isPaused) { - bufferedLogs.addAll(processedLogs) - } else { - val totalSize = allLogs.size + processedLogs.size - val removeCount = (totalSize - maxLines).coerceAtLeast(0) + viewModelScope.launch(Dispatchers.Main) { + if (_uiState.value.isPaused) { + bufferedLogs.addAll(processedLogs) + } else { + val totalSize = allLogs.size + processedLogs.size + val removeCount = (totalSize - maxLines).coerceAtLeast(0) - if (removeCount > 0) { - repeat(removeCount) { - allLogs.removeFirst() + if (removeCount > 0) { + repeat(removeCount) { + allLogs.removeFirst() + } } - } - allLogs.addAll(processedLogs) - updateDisplayedLogs() + allLogs.addAll(processedLogs) + updateDisplayedLogs() - if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { - scrollToBottom() + if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { + scrollToBottom() + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt index 8734b43..46b170f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.utils.PrivilegeSettingsClient @@ -95,10 +98,14 @@ private enum class RiskCategory { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { +fun PrivilegeSettingsManageScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortReverse by remember { mutableStateOf(false) } @@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { } if (failure != null) { syncErrorMessage = failure.message ?: failure.toString() + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt index 1c883d0..def7b1f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt @@ -92,12 +92,11 @@ fun EditProfileContentScreen( profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - profileName: String = "", isReadOnly: Boolean = false, ) { val viewModel: EditProfileContentViewModel = viewModel( - factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly), + factory = EditProfileContentViewModel.Factory(profileId, isReadOnly), ) val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt index 1c19a9b..51a082e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt @@ -38,11 +38,10 @@ data class EditProfileContentUiState( val profileName: String = "", // Add profile name ) -class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() { +class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly: Boolean = false) : ViewModel() { private val _uiState = MutableStateFlow( EditProfileContentUiState( - profileName = initialProfileName, isReadOnly = initialIsReadOnly, ), ) @@ -211,7 +210,7 @@ class EditProfileContentViewModel(private val profileId: Long, initialProfileNam originalContent = content, hasUnsavedChanges = false, isLoading = false, - // Keep profileName and isReadOnly from initial state - no need to update + profileName = loadedProfile.name, ) } } @@ -584,13 +583,12 @@ class EditProfileContentViewModel(private val profileId: Long, initialProfileNam class Factory( private val profileId: Long, - private val initialProfileName: String = "", private val initialIsReadOnly: Boolean = false, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { - return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T + return EditProfileContentViewModel(profileId, initialIsReadOnly) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt index b617b43..424319d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.compose.screen.profile +import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable @@ -64,12 +65,12 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi profileId = profileId, onNavigateBack = onNavigateBack, onNavigateToIconSelection = { currentIconId -> - navController.navigate("icon_selection/${currentIconId ?: "null"}") { + navController.navigate("icon_selection/${Uri.encode(currentIconId ?: "null")}") { launchSingleTop = true } }, - onNavigateToEditContent = { profileName, isReadOnly -> - navController.navigate("edit_content/$profileName/$isReadOnly") { + onNavigateToEditContent = { isReadOnly -> + navController.navigate("edit_content/$isReadOnly") { launchSingleTop = true } }, @@ -128,13 +129,9 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi } composable( - route = "edit_content/{profileName}/{isReadOnly}", + route = "edit_content/{isReadOnly}", arguments = listOf( - navArgument("profileName") { - type = NavType.StringType - defaultValue = "" - }, navArgument("isReadOnly") { type = NavType.BoolType defaultValue = false @@ -165,7 +162,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi ) }, ) { backStackEntry -> - val profileName = backStackEntry.arguments?.getString("profileName") ?: "" val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false EditProfileContentScreen( @@ -173,7 +169,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi onNavigateBack = { navController.popBackStack("edit_profile", inclusive = false) }, - profileName = profileName, isReadOnly = isReadOnly, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt index 84af0e0..d6a92fd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt @@ -79,7 +79,7 @@ fun EditProfileScreen( profileId: Long, onNavigateBack: () -> Unit, onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, - onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> }, + onNavigateToEditContent: (isReadOnly: Boolean) -> Unit = {}, viewModel: EditProfileViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -473,7 +473,6 @@ fun EditProfileScreen( .clip(RoundedCornerShape(12.dp)) .clickable { onNavigateToEditContent( - uiState.name, uiState.profileType == TypedProfile.Type.Remote, ) }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt index 74b8986..3c553b2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.vendor.PackageQueryManager @@ -106,10 +109,14 @@ private sealed class ScanResult { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PerAppProxyScreen(onBack: () -> Unit) { +fun PerAppProxyScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } var sortMode by remember { mutableStateOf(SortMode.NAME) } @@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { fun saveSelectedApplications(newUids: Set) { coroutineScope.launch { - Settings.perAppProxyList = buildPackageList(newUids) + withContext(Dispatchers.IO) { + Settings.perAppProxyList = buildPackageList(newUids) + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } @@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { onModeChange = { mode -> proxyMode = mode coroutineScope.launch { - Settings.perAppProxyMode = mode + withContext(Dispatchers.IO) { + Settings.perAppProxyMode = mode + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } }, onSortModeChange = { mode -> diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 8aa6d21..028d021 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -7,10 +7,15 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.text.format.Formatter import android.util.Log +import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -25,8 +30,11 @@ 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.ContentCopy import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language @@ -41,9 +49,12 @@ 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.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -62,6 +73,7 @@ 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -71,13 +83,19 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.navigation.NavController +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 +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.utils.HookStatusClient @@ -88,12 +106,16 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.xmlpull.v1.XmlPullParser +import java.io.File import java.util.Locale import android.provider.Settings as AndroidSettings -@OptIn(ExperimentalMaterial3Api::class) +@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)) }, @@ -113,10 +135,12 @@ fun AppSettingsScreen(navController: NavController) { val hasUpdate by UpdateState.hasUpdate val updateInfo by UpdateState.updateInfo val isChecking by UpdateState.isChecking + var showSourceDialog by remember { mutableStateOf(false) } + var currentSource by remember { mutableStateOf(Settings.updateSource) } var showTrackDialog by remember { mutableStateOf(false) } var currentTrack by remember { mutableStateOf(Settings.updateTrack) } var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } - var showErrorDialog by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(null) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } @@ -132,10 +156,12 @@ fun AppSettingsScreen(navController: NavController) { var downloadJob by remember { mutableStateOf(null) } var downloadError by remember { mutableStateOf(null) } var showUpdateAvailableDialog by remember { mutableStateOf(false) } + var showVersionMenu by remember { mutableStateOf(false) } 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) } @@ -144,8 +170,22 @@ fun AppSettingsScreen(navController: NavController) { mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) } + var cacheSize by remember { mutableStateOf(0L) } + var cacheSizeText by remember { mutableStateOf("") } + + fun refreshCacheSize() { + scope.launch(Dispatchers.IO) { + val size = calculateDirSize(context.cacheDir) + withContext(Dispatchers.Main) { + cacheSize = size + cacheSizeText = Formatter.formatFileSize(context, size) + } + } + } + LaunchedEffect(Unit) { HookStatusClient.refresh() + refreshCacheSize() } // Re-check states when returning from background (e.g., after granting permission) @@ -183,6 +223,21 @@ fun AppSettingsScreen(navController: NavController) { } } + if (showSourceDialog) { + UpdateSourceDialog( + currentSource = currentSource, + onSourceSelected = { source -> + currentSource = source + UpdateState.clear() + scope.launch(Dispatchers.IO) { + Settings.updateSource = source + } + showSourceDialog = false + }, + onDismiss = { showSourceDialog = false }, + ) + } + if (showTrackDialog) { UpdateTrackDialog( currentTrack = currentTrack, @@ -198,11 +253,11 @@ fun AppSettingsScreen(navController: NavController) { ) } - showErrorDialog?.let { messageRes -> + showErrorDialog?.let { message -> AlertDialog( onDismissRequest = { showErrorDialog = null }, title = { Text(stringResource(R.string.check_update)) }, - text = { Text(stringResource(messageRes)) }, + text = { Text(message) }, confirmButton = { TextButton(onClick = { showErrorDialog = null }) { Text(stringResource(R.string.ok)) @@ -223,10 +278,22 @@ fun AppSettingsScreen(navController: NavController) { color = MaterialTheme.colorScheme.error, ) } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) + val progress by UpdateState.downloadProgress + Column { + if (progress != null) { + Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%") + } else { + Text(stringResource(R.string.downloading)) + } + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + LinearProgressIndicator( + progress = { progress!! }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } } @@ -238,6 +305,7 @@ fun AppSettingsScreen(navController: NavController) { downloadJob = null showDownloadDialog = false downloadError = null + UpdateState.downloadProgress.value = null }, ) { Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) @@ -381,39 +449,70 @@ fun AppSettingsScreen(navController: NavController) { ), ) { Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.app_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (hasUpdate) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } + Box { + ListItem( + headlineContent = { + Text( + stringResource(R.string.app_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasUpdate) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .combinedClickable( + onClick = {}, + onLongClick = { showVersionMenu = true }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + DropdownMenu( + expanded = showVersionMenu, + onDismissRequest = { showVersionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + ) + }, + onClick = { + clipboardText = BuildConfig.VERSION_NAME + Toast.makeText( + context, + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + showVersionMenu = false + }, + ) } - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) + } + } ListItem( headlineContent = { @@ -440,13 +539,80 @@ fun AppSettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { showLanguageDialog = true }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, ), ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.cache_size), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + if (cacheSizeText.isNotEmpty()) { + Text(cacheSizeText, style = MaterialTheme.typography.bodyMedium) + } + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip( + if (cacheSize > 0L) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (cacheSize > 0L) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.clear_cache), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + scope.launch(Dispatchers.IO) { + context.cacheDir?.listFiles()?.forEach { it.deleteRecursively() } + withContext(Dispatchers.Main) { + cacheSize = 0L + cacheSizeText = Formatter.formatFileSize(context, 0L) + } + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } } } @@ -520,6 +686,9 @@ fun AppSettingsScreen(navController: NavController) { dynamicNotification = checked scope.launch(Dispatchers.IO) { Settings.dynamicNotification = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } } }, ) @@ -555,14 +724,21 @@ fun AppSettingsScreen(navController: NavController) { ), ) { Column { + val isFDroid = UpdateSource.fromString(currentSource) == UpdateSource.FDROID val updateItemCount = run { var count = 0 - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + count += 1 + } + if (Vendor.hasCustomUpdate) { + count += 1 + } + if (isFDroid) { count += 1 } count += 1 - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { count += 1 if (silentInstallEnabled) { count += 1 @@ -574,7 +750,7 @@ fun AppSettingsScreen(navController: NavController) { } } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { count += 1 } count @@ -592,7 +768,39 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update_source), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val sourceName = when (UpdateSource.fromString(currentSource)) { + UpdateSource.GITHUB -> stringResource(R.string.update_source_github) + UpdateSource.FDROID -> stringResource(R.string.update_source_fdroid) + } + Text(sourceName, style = MaterialTheme.typography.bodyMedium) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { showSourceDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -601,9 +809,13 @@ fun AppSettingsScreen(navController: NavController) { ) }, supportingContent = { - val trackName = when (UpdateTrack.fromString(currentTrack)) { - UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) - UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + val trackName = if (isFDroid) { + stringResource(R.string.update_track_stable) + } else { + when (UpdateTrack.fromString(currentTrack)) { + UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) + UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + } } Text(trackName, style = MaterialTheme.typography.bodyMedium) }, @@ -615,8 +827,63 @@ fun AppSettingsScreen(navController: NavController) { ) }, modifier = + updateItemModifier().let { + if (isFDroid) it.alpha(0.38f) else it.clickable { showTrackDialog = true } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (isFDroid) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val mirrorUrl = Settings.fdroidMirrorUrl + val mirrorName = remember(mirrorUrl) { + val iter = Libbox.getFDroidMirrors() + var name: String? = null + while (iter.hasNext()) { + val m = iter.next() + if (m.url == mirrorUrl) { + name = m.name + break + } + } + if (name == null) { + val customMirrors = Settings.fdroidCustomMirrors + for (entry in customMirrors) { + val parts = entry.split("|", limit = 2) + if (parts.size == 2 && parts[1] == mirrorUrl) { + name = parts[0] + break + } + } + } + name ?: mirrorUrl + } + Text( + mirrorName, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = updateItemModifier() - .clickable { showTrackDialog = true }, + .clickable { navController.navigate("settings/fdroid_mirror") }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, @@ -656,7 +923,7 @@ fun AppSettingsScreen(navController: NavController) { ), ) - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -836,7 +1103,7 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -940,15 +1207,17 @@ fun AppSettingsScreen(navController: NavController) { val result = Vendor.checkUpdateAsync() UpdateState.setUpdate(result) if (result == null) { - showErrorDialog = R.string.no_updates_available + showErrorDialog = context.getString(R.string.no_updates_available) } else { showUpdateAvailableDialog = true } } catch (_: UpdateCheckException.TrackNotSupported) { UpdateState.setUpdate(null) - showErrorDialog = R.string.update_track_not_supported - } catch (_: Exception) { + showErrorDialog = context.getString(R.string.update_track_not_supported) + } catch (e: Exception) { + Log.e("AppSettingsScreen", "checkUpdateAsync failed", e) UpdateState.setUpdate(null) + showErrorDialog = e.message } } UpdateState.isChecking.value = false @@ -998,6 +1267,53 @@ fun AppSettingsScreen(navController: NavController) { } } +@Composable +private fun UpdateSourceDialog( + currentSource: String, + onSourceSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val sources = listOf( + "github" to stringResource(R.string.update_source_github), + "fdroid" to stringResource(R.string.update_source_fdroid), + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.update_source)) }, + text = { + Column { + sources.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSourceSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentSource == value, + onClick = { onSourceSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + @Composable private fun UpdateTrackDialog( currentTrack: String, @@ -1108,6 +1424,15 @@ private fun LanguageDialog( ) } +private fun calculateDirSize(dir: File?): Long { + if (dir == null || !dir.exists()) return 0 + var size = 0L + dir.listFiles()?.forEach { file -> + size += if (file.isDirectory) calculateDirSize(file) else file.length() + } + return size +} + private fun getSupportedLocales(context: Context): List { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val localeConfig = LocaleConfig(context) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt index c23400c..618aa75 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -5,8 +5,11 @@ import android.content.Context import android.content.Intent import android.provider.DocumentsContract import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +21,7 @@ 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.ContentCopy import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Info @@ -25,6 +29,8 @@ import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +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 @@ -41,6 +47,7 @@ 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 @@ -52,11 +59,12 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.ktx.clipboardText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CoreSettingsScreen(navController: NavController) { OverrideTopBar { @@ -77,6 +85,7 @@ fun CoreSettingsScreen(navController: NavController) { val scope = rememberCoroutineScope() var dataSize by remember { mutableStateOf("") } val version = remember { Libbox.version() } + var showVersionMenu by remember { mutableStateOf(false) } var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } // Calculate data size on launch @@ -114,34 +123,66 @@ fun CoreSettingsScreen(navController: NavController) { ) { Column { // Version Info - ListItem( - headlineContent = { - Text( - stringResource(R.string.core_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - version, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) + Box { + ListItem( + headlineContent = { + Text( + stringResource(R.string.core_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + version, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .combinedClickable( + onClick = {}, + onLongClick = { showVersionMenu = true }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + DropdownMenu( + expanded = showVersionMenu, + onDismissRequest = { showVersionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + ) + }, + onClick = { + clipboardText = version + Toast.makeText( + context, + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + showVersionMenu = false + }, + ) + } + } + } // Data Size ListItem( @@ -181,57 +222,58 @@ fun CoreSettingsScreen(navController: NavController) { } } - // Options Section - Spacer(modifier = Modifier.height(16.dp)) + if (version.contains("-")) { + Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.options), - 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.disable_deprecated_warnings), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.WarningAmber, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = disableDeprecatedWarnings, - onCheckedChange = { checked -> - disableDeprecatedWarnings = checked - scope.launch(Dispatchers.IO) { - Settings.disableDeprecatedWarnings = checked - } - }, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), + Text( + text = stringResource(R.string.beta_settings), + 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.disable_deprecated_warnings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = disableDeprecatedWarnings, + onCheckedChange = { checked -> + disableDeprecatedWarnings = checked + scope.launch(Dispatchers.IO) { + Settings.disableDeprecatedWarnings = checked + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } } // Working Directory Section diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt new file mode 100644 index 0000000..1bce659 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt @@ -0,0 +1,456 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.webkit.URLUtil +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.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.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.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +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.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.style.TextOverflow +import androidx.compose.ui.unit.dp +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.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class MirrorEntry( + val url: String, + val name: String, + val country: String, + val isCustom: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FDroidMirrorScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.fdroid_mirror)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val scope = rememberCoroutineScope() + var selectedMirrorUrl by remember { mutableStateOf(Settings.fdroidMirrorUrl) } + var isTesting by remember { mutableStateOf(false) } + val latencyResults = remember { mutableStateMapOf() } + val latencyErrors = remember { mutableStateMapOf() } + var showAddForm by remember { mutableStateOf(false) } + var newMirrorName by remember { mutableStateOf("") } + var newMirrorUrl by remember { mutableStateOf("") } + var urlError by remember { mutableStateOf(null) } + val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url) + var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) } + + val builtinMirrors = remember { + val mirrors = mutableListOf() + val iter = Libbox.getFDroidMirrors() + while (iter.hasNext()) { + val m = iter.next() + mirrors.add(MirrorEntry(url = m.url, name = m.name, country = m.country)) + } + mirrors + } + + val parsedCustomMirrors = remember(customMirrors) { + customMirrors.map { entry -> + val parts = entry.split("|", limit = 2) + if (parts.size == 2) { + MirrorEntry(url = parts[1], name = parts[0], country = "", isCustom = true) + } else { + MirrorEntry(url = entry, name = entry, country = "", isCustom = true) + } + } + } + + val allMirrors = builtinMirrors + parsedCustomMirrors + + fun selectMirror(url: String) { + selectedMirrorUrl = url + Settings.fdroidMirrorUrl = url + } + + fun testAllMirrors() { + isTesting = true + latencyResults.clear() + latencyErrors.clear() + scope.launch { + allMirrors.map { mirror -> + async(Dispatchers.IO) { + val r = Libbox.pingFDroidMirror(mirror.url) + withContext(Dispatchers.Main) { + if (r.latencyMs < 0) { + latencyErrors[r.url] = true + } else { + latencyResults[r.url] = r.latencyMs + } + } + } + }.awaitAll() + val fastest = latencyResults.minByOrNull { it.value } + if (fastest != null) { + selectMirror(fastest.key) + } + isTesting = false + } + } + + val grouped = remember(builtinMirrors) { + builtinMirrors.groupBy { it.country } + } + val countryOrder = remember(grouped) { grouped.keys.toList() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + FilledTonalButton( + onClick = { testAllMirrors() }, + enabled = !isTesting, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_testing)) + } else { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_test_all)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + countryOrder.forEach { country -> + val mirrors = grouped[country] ?: return@forEach + + Text( + text = country, + 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 { + mirrors.forEachIndexed { index, mirror -> + val shape = when { + mirrors.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == mirrors.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + Text( + text = stringResource(R.string.fdroid_mirror_custom), + 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 { + parsedCustomMirrors.forEachIndexed { index, mirror -> + val isLast = index == parsedCustomMirrors.lastIndex && !showAddForm + val shape = when { + index == 0 && isLast -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + isLast -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + mirror.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + IconButton(onClick = { + val encoded = "${mirror.name}|${mirror.url}" + val newSet = customMirrors.toMutableSet() + newSet.remove(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + if (selectedMirrorUrl == mirror.url) { + selectMirror("https://f-droid.org/repo") + } + }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.fdroid_mirror_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newMirrorName, + onValueChange = { newMirrorName = it }, + label = { Text(stringResource(R.string.fdroid_mirror_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = newMirrorUrl, + onValueChange = { + newMirrorUrl = it + urlError = null + }, + label = { Text(stringResource(R.string.fdroid_mirror_url_hint)) }, + singleLine = true, + isError = urlError != null, + supportingText = urlError?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { + val url = newMirrorUrl.trim().trimEnd('/') + if (!URLUtil.isHttpsUrl(url)) { + urlError = invalidUrlMessage + return@Button + } + val name = newMirrorName.trim().ifEmpty { url } + val encoded = "$name|$url" + val newSet = customMirrors.toMutableSet() + newSet.add(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + newMirrorName = "" + newMirrorUrl = "" + urlError = null + showAddForm = false + }) { + Text(stringResource(R.string.fdroid_mirror_add_action)) + } + } + } + } else { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror_add), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip( + if (parsedCustomMirrors.isEmpty()) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ) + .clickable { showAddForm = true }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun LatencyBadge( + url: String, + latencyResults: Map, + latencyErrors: Map, +) { + val latency = latencyResults[url] + val failed = latencyErrors[url] == true + when { + latency != null -> { + Text( + text = stringResource(R.string.fdroid_mirror_latency, latency), + style = MaterialTheme.typography.labelMedium, + color = when { + latency < 100 -> MaterialTheme.colorScheme.primary + latency < 500 -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.error + }, + ) + } + failed -> { + Text( + text = stringResource(R.string.fdroid_mirror_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt index dbcf6bc..0187c20 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -62,9 +62,9 @@ import androidx.core.content.FileProvider import androidx.navigation.NavController import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings @@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status val context = LocalContext.current val scope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val systemHookStatus by HookStatusClient.status.collectAsState() var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } @@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (checked && serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 22370e8..6d1688b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers @@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileOverrideScreen(navController: NavController) { +fun ProfileOverrideScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.profile_override)) }, @@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) { var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var isScanning by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) - fun scanAndSaveManagedList() { + fun scanAndSaveManagedList(shouldNotify: Boolean = false) { isScanning = true scope.launch { val chinaApps = PerAppProxyScanner.scanAllChinaApps() @@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyManagedList = chinaApps } isScanning = false + if (shouldNotify) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } @@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } @@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) { withContext(Dispatchers.IO) { Settings.autoRedirect = true } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } else { Toast.makeText( context, @@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) { autoRedirect = false scope.launch(Dispatchers.IO) { Settings.autoRedirect = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = checked scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = checked + if (!checked || !managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (checked && managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } } }, @@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = true } - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } else { managedModeEnabled = false scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = true scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = true + if (!managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } }, ) { @@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } else { showRootDialog = false @@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = false } } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( @@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index 7f89fff..2171997 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -26,8 +28,11 @@ import androidx.compose.material3.CardDefaults 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.OutlinedButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -35,22 +40,40 @@ import androidx.compose.runtime.LaunchedEffect 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.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle 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)) }, @@ -66,14 +89,14 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi } val context = LocalContext.current - // Check battery optimization status + val scope = rememberCoroutineScope() var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } - // Activity result launcher for battery optimization permission + var allowBypass by remember { mutableStateOf(Settings.allowBypass) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val requestBatteryOptimizationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { _ -> - // Recheck the status after returning from settings if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = context.getSystemService(PowerManager::class.java) isBatteryOptimizationIgnored = @@ -81,7 +104,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi } } - // Check battery optimization status on launch LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = context.getSystemService(PowerManager::class.java) @@ -100,7 +122,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { - // Background Permission Card (only show if battery optimization is not ignored) if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Card( modifier = @@ -171,6 +192,96 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi } } + // VPN Section + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "VPN", + 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 descriptionText = stringResource(R.string.allow_bypass_description) + val linkText = stringResource(R.string.android_documentation) + val linkColor = MaterialTheme.colorScheme.primary + val textColor = MaterialTheme.colorScheme.onSurfaceVariant + val textStyle = MaterialTheme.typography.bodyMedium + + ListItem( + headlineContent = { + Text( + stringResource(R.string.allow_bypass), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val annotatedString = buildAnnotatedString { + withStyle(SpanStyle(color = textColor)) { + append(descriptionText) + } + append("\n\n") + pushStringAnnotation(tag = "URL", annotation = ALLOW_BYPASS_DOC_URL) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(linkText) + } + pop() + } + ClickableText( + text = annotatedString, + style = textStyle, + modifier = Modifier.padding(top = 4.dp), + onClick = { offset -> + annotatedString.getStringAnnotations( + tag = "URL", + start = offset, + end = offset, + ).firstOrNull()?.let { + context.launchCustomTab(it.item) + } + }, + ) + }, + trailingContent = { + Switch( + checked = allowBypass, + onCheckedChange = { checked -> + allowBypass = checked + scope.launch(Dispatchers.IO) { + Settings.allowBypass = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + Spacer(modifier = Modifier.height(16.dp)) } } + +private const val ALLOW_BYPASS_DOC_URL = + "https://developer.android.com/reference/android/net/VpnService.Builder#allowBypass()" diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index f468f51..b7442c9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -1,7 +1,5 @@ package io.nekohasekai.sfa.compose.screen.settings -import android.os.Build -import android.os.PowerManager import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -37,10 +35,7 @@ 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.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -70,15 +65,8 @@ fun SettingsScreen(navController: NavController) { val hookStatus by HookStatusClient.status.collectAsState() val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) - var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { HookStatusClient.refresh() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pm = context.getSystemService(PowerManager::class.java) - isBatteryOptimizationIgnored = - pm?.isIgnoringBatteryOptimizations(context.packageName) == true - } } Column( @@ -167,11 +155,6 @@ fun SettingsScreen(navController: NavController) { tint = MaterialTheme.colorScheme.primary, ) }, - trailingContent = { - if (!isBatteryOptimizationIgnored) { - Badge(containerColor = MaterialTheme.colorScheme.primary) - } - }, modifier = Modifier.clickable { navController.navigate("settings/service") }, colors = ListItemDefaults.colors( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt new file mode 100644 index 0000000..bd7d712 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -0,0 +1,459 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReport +import io.nekohasekai.sfa.bg.CrashReportFile +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportDetailScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = CrashReportManager.availableFiles(report) + } + CrashReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && CrashReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + CrashReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + CrashReportFile.Kind.METADATA -> Icons.Default.DataObject + CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal + CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport + CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + if (file.kind == CrashReportFile.Kind.METADATA) { + navController.navigate("tools/crash_report/$reportId/metadata") + } else { + navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}") + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportMetadataScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + ) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + key, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(value) + }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } + } + } +} + +private fun loadMetadataEntries(report: CrashReport): List> { + val metadataFile = CrashReportManager.availableFiles(report) + .find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull() + val displayName = when (kind) { + CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log) + CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log) + CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration) + else -> fileKind + } + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = CrashReportManager.availableFiles(report).find { it.kind == kind } + content = if (file != null) CrashReportManager.loadFileContent(file) else "" + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt new file mode 100644 index 0000000..aa238a0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt @@ -0,0 +1,263 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.FlashOn +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportListScreen(navController: NavController) { + val reports by CrashReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + var crashTriggerExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + CrashReportManager.refresh() + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.crash_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (reports.isNotEmpty() || BuildConfig.DEBUG) { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { + menuExpanded = false + crashTriggerExpanded = false + }, + ) { + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("Crash Trigger") }, + leadingIcon = { + Icon( + Icons.Outlined.FlashOn, + contentDescription = null, + ) + }, + onClick = { crashTriggerExpanded = !crashTriggerExpanded }, + ) + if (crashTriggerExpanded) { + DropdownMenuItem( + text = { + Text( + "Go Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Libbox.triggerGoPanic() + }, + ) + DropdownMenuItem( + text = { + Text( + "Native Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + }, + ) + } + } + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { + CrashReportManager.deleteAll() + } + }, + ) + } + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/crash_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.crash_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt new file mode 100644 index 0000000..63d8963 --- /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 0000000..30b0fbc --- /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 0000000..f7936ac --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -0,0 +1,451 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReport +import io.nekohasekai.sfa.bg.OOMReportFile +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportDetailScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = OOMReportManager.availableFiles(report) + } + OOMReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && OOMReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + OOMReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + OOMReportFile.Kind.METADATA -> Icons.Default.DataObject + OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings + OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .then( + if (file.kind != OOMReportFile.Kind.PROFILE) { + Modifier.clickable { + if (file.kind == OOMReportFile.Kind.METADATA) { + navController.navigate("tools/oom_report/$reportId/metadata") + } else { + navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}") + } + } + } else { + Modifier + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportMetadataScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadOOMMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text(key, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { Text(value) }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } +} + +private fun loadOOMMetadataEntries(report: OOMReport): List> { + val metadataFile = OOMReportManager.availableFiles(report) + .find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(fileKind) } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull() + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = OOMReportManager.availableFiles(report).find { it.kind == kind } + if (file != null) { + displayName = file.displayName + content = OOMReportManager.loadFileContent(file) + } + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt new file mode 100644 index 0000000..94baff3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt @@ -0,0 +1,416 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Date + +private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportListScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { + val reports by OOMReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf(null) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) + + var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) } + var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) } + var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) } + var showMemoryLimitDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + OOMReportManager.refresh() + val storedLimit = Settings.oomMemoryLimitMB + if (!memoryLimitOptions.contains(storedLimit)) { + oomMemoryLimitMB = memoryLimitOptions.first() + Settings.oomMemoryLimitMB = oomMemoryLimitMB + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.oom_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.oom_report_fetch)) }, + leadingIcon = { + Icon(Icons.Outlined.Memory, contentDescription = null) + }, + onClick = { + menuExpanded = false + if (serviceStatus != Status.Started) { + errorMessage = + Application.application.getString(R.string.service_not_started) + } else { + scope.launch { + val failure = + withContext(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().triggerOOMReport() + }.exceptionOrNull() + } + if (failure != null) { + errorMessage = failure.message ?: failure.toString() + } else { + delay(1000) + withContext(Dispatchers.IO) { + OOMReportManager.refresh() + } + } + } + } + }, + ) + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { OOMReportManager.deleteAll() } + }, + ) + } + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Reports section + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/oom_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.oom_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + // Settings section + Text( + stringResource(R.string.title_settings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_enable_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_enable_memory_limit_description)) + }, + trailingContent = { + Switch( + checked = oomKillerEnabled, + onCheckedChange = { checked -> + oomKillerEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerEnabled = checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + AnimatedVisibility(visible = oomKillerEnabled) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L)) + }, + modifier = Modifier.clickable { showMemoryLimitDialog = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_kill_connections), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_kill_connections_description)) + }, + trailingContent = { + Switch( + checked = oomKillerKillConnections, + onCheckedChange = { checked -> + oomKillerKillConnections = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerDisabled = !checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } + } + + errorMessage?.let { message -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + confirmButton = { + TextButton(onClick = { errorMessage = null }) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { Text(message) }, + ) + } + + if (showMemoryLimitDialog) { + AlertDialog( + onDismissRequest = { showMemoryLimitDialog = false }, + title = { Text(stringResource(R.string.oom_report_memory_limit)) }, + text = { + Column { + memoryLimitOptions.forEach { value -> + ListItem( + headlineContent = { + Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L)) + }, + leadingContent = { + RadioButton( + selected = value == oomMemoryLimitMB, + onClick = null, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + oomMemoryLimitMB = value + showMemoryLimitDialog = false + scope.launch(Dispatchers.IO) { + Settings.oomMemoryLimitMB = value + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt new file mode 100644 index 0000000..c20fa4c --- /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 0000000..24c02e4 --- /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 0000000..9681283 --- /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 0000000..0b7405c --- /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 0000000..60e3090 --- /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 0000000..c821186 --- /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 0000000..9109e26 --- /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 0000000..1de43e4 --- /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 0000000..b0e0207 --- /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 3109681..8454278 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -5,7 +5,10 @@ object SettingsKey { const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val UPDATE_CHECK_PROMPTED = "update_check_prompted" + const val UPDATE_SOURCE = "update_source" const val UPDATE_TRACK = "update_track" + const val FDROID_MIRROR_URL = "fdroid_mirror_url" + const val FDROID_CUSTOM_MIRRORS = "fdroid_custom_mirrors" const val SILENT_INSTALL_ENABLED = "silent_install_enabled" const val SILENT_INSTALL_METHOD = "silent_install_method" const val AUTO_UPDATE_ENABLED = "auto_update_enabled" @@ -20,6 +23,7 @@ object SettingsKey { const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" + const val ALLOW_BYPASS = "allow_bypass" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" @@ -27,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 23b1d8b..721ac01 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -41,6 +41,7 @@ object Settings { var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) + var updateSource by dataStore.string(SettingsKey.UPDATE_SOURCE) { "github" } var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { @@ -62,6 +63,8 @@ object Settings { "SHIZUKU" } } + var fdroidMirrorUrl by dataStore.string(SettingsKey.FDROID_MIRROR_URL) { "https://f-droid.org/repo" } + var fdroidCustomMirrors by dataStore.stringSet(SettingsKey.FDROID_CUSTOM_MIRRORS) { emptySet() } var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } @@ -93,6 +96,7 @@ object Settings { perAppProxyList } + var allowBypass by dataStore.boolean(SettingsKey.ALLOW_BYPASS) { false } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } @@ -102,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/update/FDroidUpdateChecker.kt b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt new file mode 100644 index 0000000..98af76f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sfa.update + +import android.content.Context +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.database.Settings + +fun checkFDroidUpdate(context: Context): UpdateInfo? { + val packageName = context.packageName + + @Suppress("DEPRECATION") + val versionCode = context.packageManager.getPackageInfo(packageName, 0).versionCode + val result = Libbox.checkFDroidUpdate( + Settings.fdroidMirrorUrl, + packageName, + versionCode, + context.cacheDir.absolutePath, + ) ?: return null + return UpdateInfo( + versionCode = result.versionCode, + versionName = result.versionName, + downloadUrl = result.downloadURL, + releaseUrl = "https://f-droid.org/packages/$packageName/", + releaseNotes = null, + isPrerelease = false, + fileSize = result.fileSize, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt new file mode 100644 index 0000000..006ad5f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.update + +enum class UpdateSource { + GITHUB, + FDROID, + ; + + companion object { + fun fromString(value: String): UpdateSource = when (value.lowercase()) { + "fdroid" -> FDROID + else -> GITHUB + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt index 17efa3d..19ba94e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt @@ -11,6 +11,7 @@ object UpdateState { val isChecking = mutableStateOf(false) val isDownloading = mutableStateOf(false) + val downloadProgress = mutableStateOf(null) val downloadError = mutableStateOf(null) val cachedApkFile = mutableStateOf(null) @@ -38,6 +39,7 @@ object UpdateState { hasUpdate.value = false updateInfo.value = null isDownloading.value = false + downloadProgress.value = null downloadError.value = null installStatus.value = InstallStatus.Idle cachedApkFile.value = null @@ -46,6 +48,7 @@ object UpdateState { fun resetDownload() { isDownloading.value = false + downloadProgress.value = null downloadError.value = null } 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 c5b7681..b70bdd8 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,12 +103,18 @@ open class CommandClient( ConnectionType.Log -> Libbox.CommandLog ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds } options.addCommand(command) } options.statusInterval = 1 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) - commandClient.connect() + try { + commandClient.connect() + } catch (e: Exception) { + Log.d("CommandClient", "connect failed", e) + return + } this.commandClient = commandClient } @@ -137,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/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index e72e00c..db8fd2a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -4,6 +4,7 @@ import android.app.Activity import androidx.camera.core.ImageAnalysis import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource interface VendorInterface { fun checkUpdate(activity: Activity, byUser: Boolean) @@ -14,53 +15,17 @@ interface VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)? = null, ): ImageAnalysis.Analyzer? - /** - * Check if Per-app Proxy feature is available - * @return true if available, false if disabled (e.g., for Play Store builds) - */ fun isPerAppProxyAvailable(): Boolean = true - /** - * Check if track selection is available (e.g., stable/beta) - * @return true if track selection is supported - */ - fun supportsTrackSelection(): Boolean = false + val hasCustomUpdate: Boolean get() = false + + val updateSources: List get() = listOf(UpdateSource.GITHUB) - /** - * Check for updates asynchronously - * @return UpdateInfo if update is available, null otherwise - */ fun checkUpdateAsync(): UpdateInfo? = null - /** - * Check if silent install feature is available - * @return true if silent install is supported (Other flavor only) - */ - fun supportsSilentInstall(): Boolean = false - - /** - * Check if auto update feature is available - * @return true if auto update is supported (Other flavor only) - */ - fun supportsAutoUpdate(): Boolean = false - - /** - * Schedule auto update worker - */ fun scheduleAutoUpdate() {} - /** - * Verify if the specified silent install method is available - * @param method The install method (SHIZUKU or ROOT) - * @return true if the method is available and working - */ suspend fun verifySilentInstallMethod(method: String): Boolean = false - /** - * Download and install an APK update - * @param context The context - * @param downloadUrl The URL to download the APK from - * @throws Exception if download or install fails - */ suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt new file mode 100644 index 0000000..08d4c2b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt @@ -0,0 +1,49 @@ +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 +import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName +import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages + +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") + val hooks = arrayOf( + ConnectivityServiceHookHelper(classLoader), + HookIConnectivityManagerOnTransact(classLoader, systemContext), + HookPackageManagerGetInstalledPackages(classLoader), + HookNetworkCapabilitiesWriteToParcel(), + HookNetworkInterfaceGetName(classLoader), + ) + + hooks.forEach { hook -> + try { + hook.injectHook() + } catch (e: Throwable) { + HookErrorStore.e( + TAG, + "Failed to inject ${hook.javaClass.simpleName}", + e, + ) + } + } + } + + 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 10fe4a2..522da95 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt @@ -1,54 +1,16 @@ package io.nekohasekai.sfa.xposed -import android.content.Context import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface -import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact -import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName -import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { - 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") } - override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { - val systemContext = resolveSystemContext() - HookErrorStore.i("XposedInit", "handleSystemServerLoaded") - val hooks = arrayOf( - ConnectivityServiceHookHelper(param.classLoader), - HookIConnectivityManagerOnTransact(param.classLoader, systemContext), - HookPackageManagerGetInstalledPackages(param.classLoader), - HookNetworkCapabilitiesWriteToParcel(), - HookNetworkInterfaceGetName(param.classLoader), - ) - - hooks.forEach { hook -> - try { - hook.injectHook() - } catch (e: Throwable) { - HookErrorStore.e( - "XposedInit", - "Failed to inject ${hook.javaClass.simpleName}", - e, - ) - } - } + HookInstaller.install(param.classLoader) } companion object { const val TAG = "sing-box-lsposed" } - - private fun resolveSystemContext(): Context? = try { - val currentThread = currentActivityThreadMethod.invoke(null) - getSystemContextMethod.invoke(currentThread) as? Context - } catch (e: Throwable) { - HookErrorStore.e("XposedInit", "resolveSystemContext failed", e) - null - } } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt new file mode 100644 index 0000000..e5504a8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt @@ -0,0 +1,11 @@ +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/hidevpn/ConnectivityServiceHookHelper.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt index 39b0b3b..3298b2b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt @@ -6,6 +6,7 @@ import android.net.Network import android.net.NetworkInfo import android.os.Build import android.os.IBinder +import android.os.Parcel import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedHelpers import io.nekohasekai.sfa.xposed.HookErrorStore @@ -26,6 +27,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo private val hooked = AtomicBoolean(false) private val initializerHooked = AtomicBoolean(false) private var classLoadUnhook: XC_MethodHook.Unhook? = null + private var onTransactUnhook: XC_MethodHook.Unhook? = null private val serviceManagerHooked = AtomicBoolean(false) private var connectivityClassLoader: ClassLoader = classLoader private val skipLogKeys = ConcurrentHashMap() @@ -53,6 +55,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } hookConnectivityServiceInitializer() hookClassLoaderFallback() + hookOnTransactFallback() tryHookFromServiceManager() } @@ -148,12 +151,39 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } } HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") + + val initializerNames = listOf( + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + ) + for (name in initializerNames) { + for (loader in loaders) { + val initCls = try { + if (loader != null) Class.forName(name, false, loader) else Class.forName(name) + } catch (_: Throwable) { + null + } ?: continue + try { + val field = initCls.getDeclaredField("mConnectivity") + val fieldType = field.type + if (fieldType.name.endsWith(".ConnectivityService")) { + HookErrorStore.i( + SOURCE, + "ConnectivityService class found via $name.mConnectivity: ${fieldType.name}", + ) + return fieldType + } + } catch (_: Throwable) { + } + } + } + return null } private fun hookConnectivityServiceInitializer() { - if (sdkInt < 31 || sdkInt >= 33) { - HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") + if (sdkInt < 31) { + HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (requires API 31+)") return } val candidates = listOf( @@ -238,20 +268,20 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo classLoadUnhook = null return } - when (name) { - "com.android.server.ConnectivityService" -> { + when { + name == "com.android.server.ConnectivityService" || + name.endsWith(".com.android.server.ConnectivityService") -> { val cls = param.result as? Class<*> ?: return HookErrorStore.i( SOURCE, - "ConnectivityService loaded via ${param.thisObject.javaClass.name}", + "ConnectivityService loaded via ${param.thisObject.javaClass.name}: $name", ) installHooks(cls, "loadClass") classLoadUnhook?.unhook() classLoadUnhook = null } - "com.android.server.ConnectivityServiceInitializer", - "com.android.server.ConnectivityServiceInitializerB", - -> { + name == "com.android.server.ConnectivityServiceInitializer" || + name == "com.android.server.ConnectivityServiceInitializerB" -> { if (sdkInt < 31) return if (initializerHooked.get()) return val cls = param.result as? Class<*> ?: return @@ -322,6 +352,41 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } } + private fun hookOnTransactFallback() { + if (onTransactUnhook != null) return + try { + val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader) + onTransactUnhook = XposedHelpers.findAndHookMethod( + stub, + "onTransact", + Int::class.javaPrimitiveType, + Parcel::class.java, + Parcel::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + if (hooked.get()) { + onTransactUnhook?.unhook() + onTransactUnhook = null + return + } + val serviceClass = param.thisObject.javaClass + HookErrorStore.i( + SOURCE, + "ConnectivityService discovered via onTransact: ${serviceClass.name}", + ) + installHooks(serviceClass, "onTransact") + onTransactUnhook?.unhook() + onTransactUnhook = null + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.Stub.onTransact for discovery") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook onTransact fallback failed: ${e.message}", e) + } + } + private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { if (sdkInt < 31) return if (initializerHooked.get()) return diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 14e6cc8..7c0215f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -23,6 +23,8 @@ اقدام شروع لغو انتخاب + بارگذاری مجدد + راه‌اندازی مجدد باز کردن جمع کردن باز کردن همه @@ -198,12 +200,18 @@ کد منبع حامی مالی پوشه کاری + تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ + اندازه حافظه پنهان + پاک‌سازی حافظه پنهان اعلان‌ها فعال‌کردن اعلان نمایش سرعت بلادرنگ در اعلان به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید. به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید. + اجازه دور زدن VPN + در صورت فعال بودن، برنامه‌ها می‌توانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند. + مستندات Android تغییر مسیر خودکار نیازمند دسترسی ROOT پراکسی HTTP سیستم @@ -275,6 +283,22 @@ نسخه جدید موجود است: %s به‌روزرسانی خودکار دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه + منبع به‌روزرسانی + GitHub + F-Droid + آینه F-Droid + انتخاب خودکار بر اساس تأخیر + در حال تست… + %d ms + ناموفق + + افزودن آینه + نام + URL + سفارشی + URL نامعتبر + افزودن + حذف نصب بی‌صدا @@ -402,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 ae2aa0d..50fc5e4 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 @@ Действие Начать Отменить выбор + Перезагрузить + Перезапустить Развернуть Свернуть Развернуть все @@ -198,12 +200,18 @@ Исходный код Поддержать Рабочая директория + Бета-настройки Отключить предупреждения об устаревании + Размер кэша + Очистить кэш Уведомления Включить уведомления Отображать скорость в реальном времени в уведомлении Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках. Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении. + Разрешить обход VPN + Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую. + Документация Android Автоматическое перенаправление Требуются права ROOT Системный HTTP-прокси @@ -275,6 +283,22 @@ Доступна новая версия: %s Автообновление Автоматически загружать и устанавливать обновления в фоне + Источник обновлений + GitHub + F-Droid + Зеркало F-Droid + Автовыбор по задержке + Тестирование… + %d мс + Ошибка + + Добавить зеркало + Имя + URL + Пользовательское + Недопустимый URL + Добавить + Удалить Тихая установка @@ -408,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 8d06b38..a6ad323 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 @@ 搜索连接… 关闭所有连接? 全部 - 活跃 + 活动 已关闭 日期 流量 @@ -198,12 +200,18 @@ 源代码 赞助 工作目录 + Beta 版设置 禁用弃用警告 + 缓存大小 + 清除缓存 通知 启用通知 在通知中显示实时网速 由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。 由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。 + 允许绕过 VPN + 启用后,应用可以绕过此 VPN 连接,直接使用底层网络。 + Android 文档 自动重定向 需要 ROOT 权限 系统 HTTP 代理 @@ -266,7 +274,7 @@ 是否启用从 **GitHub** 自动检查更新? 更新轨道 稳定版 - 测试版 + Beta 版 当前轨道尚不支持检查更新 查看发布 下载中… @@ -275,6 +283,22 @@ 有新版本可用:%s 自动更新 在后台自动下载和安装更新 + 更新来源 + GitHub + F-Droid + F-Droid 镜像 + 根据延迟自动选择 + 测试中… + %d ms + 失败 + + 添加镜像 + 名称 + URL + 自定义 + 无效的 URL + 添加 + 删除 静默安装 @@ -399,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 b9a0b99..f3208e7 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 @@ 搜尋連線… 關閉所有連線? 全部 - 活躍 + 活動 已關閉 日期 流量 @@ -198,12 +200,18 @@ 原始碼 贊助 工作目錄 + Beta 版設定 停用過時警告 + 快取大小 + 清除快取 通知 啟用通知 在通知中顯示即時網速 由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。 由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。 + 允許繞過 VPN + 啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。 + Android 文件 自動重定向 需要 ROOT 權限 系統 HTTP 代理 @@ -266,7 +274,7 @@ 是否啟用從 **GitHub** 自動檢查更新? 更新通道 穩定版 - 測試版 + Beta 版 目前通道尚不支援檢查更新 查看發布 下載中… @@ -275,6 +283,22 @@ 有新版本可用:%s 自動更新 在背景自動下載並安裝更新 + 更新來源 + GitHub + F-Droid + F-Droid 鏡像 + 依延遲自動選擇 + 測試中… + %d ms + 失敗 + + 新增鏡像 + 名稱 + URL + 自訂 + 無效的 URL + 新增 + 刪除 靜默安裝 @@ -402,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 c9980c2..70e31fe 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 @@ -198,12 +200,18 @@ Source Code Sponsor Working Directory + Beta Settings Disable Deprecated Warnings + Cache Size + Clear Cache Notification Enable Notification Display realtime speed in notification Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category. Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications. + Allow Bypass + If enabled, applications can bypass this VPN connection and instead use the underlying network directly. + Android Documentation Auto Redirect ROOT permission required System HTTP Proxy @@ -264,6 +272,9 @@ Automatic Update Check Would you like to enable automatic update checking from **Play Store**? Would you like to enable automatic update checking from **GitHub**? + Update Source + GitHub + F-Droid Update Track Stable Beta @@ -275,6 +286,19 @@ New version available: %s Auto Update Automatically download and install updates in background + F-Droid Mirror + Auto Select by Latency + Testing… + %d ms + Failed + + Add Mirror + Name + URL + Custom + Invalid URL + Add + Delete Silent Install @@ -402,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/res/xml/cache_paths.xml b/app/src/main/res/xml/cache_paths.xml index c782c28..e1d9237 100644 --- a/app/src/main/res/xml/cache_paths.xml +++ b/app/src/main/res/xml/cache_paths.xml @@ -3,4 +3,7 @@ + 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 54a7373..06d0239 100644 --- a/app/src/main/resources/META-INF/xposed/java_init.list +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -1 +1,2 @@ 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 8dc7ff3..ec34252 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 -targetApiVersion=100 +targetApiVersion=101 staticScope=true diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 1b0809c..32876c4 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -13,8 +13,10 @@ import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate object Vendor : VendorInterface { private const val TAG = "Vendor" @@ -93,19 +95,20 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true - override fun checkUpdateAsync(): UpdateInfo? { - val track = UpdateTrack.fromString(Settings.updateTrack) - return GitHubUpdateChecker().use { checker -> - checker.checkUpdate(track) + override val updateSources = listOf(UpdateSource.GITHUB, UpdateSource.FDROID) + + override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(Application.application) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { checker -> + checker.checkUpdate(track) + } } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index d34525d..835264d 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -93,7 +93,7 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true override fun checkUpdateAsync(): UpdateInfo? { val track = UpdateTrack.fromString(Settings.updateTrack) @@ -102,10 +102,6 @@ object Vendor : VendorInterface { } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index daf9b5f..c8689ae 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -92,7 +92,5 @@ object Vendor : VendorInterface { } } - override fun supportsTrackSelection(): Boolean = false - override fun checkUpdateAsync(): UpdateInfo? = null } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d88dc34..d54d9f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -spotless = "8.1.0" +spotless = "8.2.1" ktlint = "1.7.1" [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 477070d..2a565f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Mon Jul 07 14:05:29 CST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME 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 index 425596f..71eba22 100644 --- 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 @@ -21,9 +21,19 @@ import io.github.libxposed.api.utils.DexParser; */ public class XposedInterfaceWrapper implements XposedInterface { - private final XposedInterface mBase; + private volatile XposedInterface mBase; - XposedInterfaceWrapper(@NonNull XposedInterface base) { + 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; } 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 index b2e1a03..0c8c755 100644 --- 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 @@ -9,11 +9,16 @@ import androidx.annotation.NonNull; @SuppressWarnings("unused") public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { /** - * Instantiates a new Xposed module.
- * When the module is loaded into the target process, the constructor will be called. - * - * @param base The implementation interface provided by the framework, should not be used by the module - * @param param Information about the process in which the module is loaded + * 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 index 1cb548c..953edac 100644 --- 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 @@ -1,5 +1,6 @@ package io.github.libxposed.api; +import android.app.AppComponentFactory; import android.content.pm.ApplicationInfo; import android.os.Build; @@ -32,7 +33,7 @@ public interface XposedModuleInterface { } /** - * Wraps information about system server. + * Wraps information about system server. API 100 flavor. */ interface SystemServerLoadedParam { /** @@ -44,6 +45,26 @@ public interface XposedModuleInterface { 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. */ @@ -99,10 +120,28 @@ public interface XposedModuleInterface { } /** - * Gets notified when the system server is loaded. + * 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/version.properties b/version.properties index f67de25..aa8752d 100644 --- a/version.properties +++ b/version.properties @@ -2,4 +2,3 @@ VERSION_CODE=627 VERSION_NAME=1.13.0-rc.7 GO_VERSION=go1.25.7 -