merge upstream

This commit is contained in:
n3t1zen
2026-05-03 23:44:08 +08:00
89 changed files with 7855 additions and 454 deletions

View File

@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.vendor package io.nekohasekai.sfa.vendor
import io.nekohasekai.libbox.HTTPResponseWriteToProgressHandler
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
@@ -27,7 +28,15 @@ class ApkDownloader : Closeable {
request.setURL(url) request.setURL(url)
val response = request.execute() 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) { if (!apkFile.exists() || apkFile.length() == 0L) {
throw Exception("Download failed: empty file") throw Exception("Download failed: empty file")

View File

@@ -86,9 +86,7 @@ class GitHubUpdateChecker : Closeable {
} }
} }
private fun isNewerThanCurrent(versionName: String): Boolean { private fun isNewerThanCurrent(versionName: String): Boolean = Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME)
return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME)
}
private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean {
if (Libbox.compareSemver(version.versionName, other.versionName)) { if (Libbox.compareSemver(version.versionName, other.versionName)) {

View File

@@ -11,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.update.checkFDroidUpdate
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { 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...") Log.d(TAG, "Checking for updates...")
return try { return try {
val track = UpdateTrack.fromString(Settings.updateTrack) val updateInfo = when (UpdateSource.fromString(Settings.updateSource)) {
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } UpdateSource.FDROID -> checkFDroidUpdate(appContext)
UpdateSource.GITHUB -> {
val track = UpdateTrack.fromString(Settings.updateTrack)
GitHubUpdateChecker().use { it.checkUpdate(track) }
}
}
if (updateInfo == null) { if (updateInfo == null) {
Log.d(TAG, "No update available") Log.d(TAG, "No update available")

View File

@@ -0,0 +1,7 @@
package io.nekohasekai.sfa.bg;
import io.nekohasekai.sfa.bg.ParceledListSlice;
interface INeighborTableCallback {
oneway void onNeighborTableUpdated(in ParceledListSlice entries);
}

View File

@@ -1,6 +1,7 @@
package io.nekohasekai.sfa.bg; package io.nekohasekai.sfa.bg;
import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor;
import io.nekohasekai.sfa.bg.INeighborTableCallback;
import io.nekohasekai.sfa.bg.ParceledListSlice; import io.nekohasekai.sfa.bg.ParceledListSlice;
interface IRootService { interface IRootService {
@@ -11,4 +12,8 @@ interface IRootService {
void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2;
String exportDebugInfo(String outputPath) = 3; String exportDebugInfo(String outputPath) = 3;
void registerNeighborTableCallback(in INeighborTableCallback callback) = 4;
oneway void unregisterNeighborTableCallback(in INeighborTableCallback callback) = 5;
} }

View File

@@ -0,0 +1,3 @@
package io.nekohasekai.sfa.bg;
parcelable NeighborEntry;

View File

@@ -9,13 +9,16 @@ import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.PowerManager import android.os.PowerManager
import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import go.Seq
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.libbox.SetupOptions
import io.nekohasekai.sfa.bg.AppChangeReceiver 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.bg.UpdateProfileWork
import io.nekohasekai.sfa.constant.Bugs import io.nekohasekai.sfa.constant.Bugs
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.AppLifecycleObserver
import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
@@ -39,13 +42,28 @@ class Application : Application() {
AppLifecycleObserver.register(this) AppLifecycleObserver.register(this)
// Seq.setContext(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) HookStatusClient.register(this)
PrivilegeSettingsClient.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") @Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
initialize() initialize(baseDir, workingDir, tempDir)
UpdateProfileWork.reconfigureUpdater() UpdateProfileWork.reconfigureUpdater()
HookModuleUpdateNotifier.sync(this@Application) 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 val baseDir = filesDir
baseDir.mkdirs()
val workingDir = getExternalFilesDir(null) ?: return val workingDir = getExternalFilesDir(null) ?: return
workingDir.mkdirs()
val tempDir = cacheDir val tempDir = cacheDir
tempDir.mkdirs() Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir))
Libbox.setup( }
SetupOptions().also {
it.basePath = baseDir.path private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) {
it.workingPath = workingDir.path Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir))
it.tempPath = tempDir.path }
it.fixAndroidStack = Bugs.fixAndroidStack
it.logMaxLines = 3000 private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also {
it.debug = BuildConfig.DEBUG it.basePath = baseDir.path
}, it.workingPath = workingDir.path
) it.tempPath = tempDir.path
Libbox.redirectStderr(File(workingDir, "stderr.log").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 { companion object {

View File

@@ -21,6 +21,11 @@ class BootReceiver : BroadcastReceiver() {
} }
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
if (Settings.startedByUser) { if (Settings.startedByUser) {
CrashReportManager.refresh()
if (CrashReportManager.unreadCount.value > 0) {
Settings.startedByUser = false
return@launch
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
BoxService.start() BoxService.start()
} }

View File

@@ -162,7 +162,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
} }
if (!service.hasPermission(wifiPermission)) { if (!service.hasPermission(wifiPermission)) {
closeService()
stopAndAlert(Alert.RequestLocationPermission) stopAndAlert(Alert.RequestLocationPermission)
return return
} }
@@ -243,7 +242,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
} }
if (!service.hasPermission(wifiPermission)) { if (!service.hasPermission(wifiPermission)) {
closeService()
stopAndAlert(Alert.RequestLocationPermission) stopAndAlert(Alert.RequestLocationPermission)
return return
} }
@@ -311,6 +309,16 @@ class BoxService(private val service: Service, private val platformInterface: Pl
private suspend fun stopAndAlert(type: Alert, message: String? = null) { private suspend fun stopAndAlert(type: Alert, message: String? = null) {
Settings.startedByUser = false Settings.startedByUser = false
val pfd = fileDescriptor
if (pfd != null) {
pfd.close()
fileDescriptor = null
}
DefaultNetworkMonitor.stop()
if (::commandServer.isInitialized) {
closeService()
commandServer.close()
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (receiverRegistered) { if (receiverRegistered) {
service.unregisterReceiver(receiver) service.unregisterReceiver(receiver)
@@ -321,6 +329,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl
callback.onServiceAlert(type.ordinal, message) callback.onServiceAlert(type.ordinal, message)
} }
status.value = Status.Stopped 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?) { override fun writeDebugMessage(message: String?) {
Log.d("sing-box", message!!) Log.d("sing-box", message!!)
} }

View File

@@ -0,0 +1,251 @@
package io.nekohasekai.sfa.bg
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class CrashReport(
val id: String,
val date: Date,
val directory: File,
val isRead: Boolean,
)
data class CrashReportFile(
val kind: Kind,
val displayName: String,
val file: File,
) {
enum class Kind {
METADATA,
GO_LOG,
JVM_LOG,
CONFIG,
}
}
object CrashReportManager {
private const val METADATA_FILE_NAME = "metadata.json"
private const val GO_LOG_FILE_NAME = "go.log"
private const val JVM_LOG_FILE_NAME = "jvm.log"
private const val CONFIG_FILE_NAME = "configuration.json"
private const val READ_MARKER_FILE_NAME = ".read"
private const val CRASH_REPORTS_DIR_NAME = "crash_reports"
private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log"
private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json"
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private lateinit var workingDir: File
private lateinit var baseDir: File
private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
val reports: StateFlow<List<CrashReport>> = _reports
private val _unreadCount = MutableStateFlow(0)
val unreadCount: StateFlow<Int> = _unreadCount
fun install(workingDir: File, baseDir: File) {
this.workingDir = workingDir
this.baseDir = baseDir
archivePendingJvmCrashReport()
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
writePendingJvmCrashReport(thread, throwable)
previous?.uncaughtException(thread, throwable)
}
}
private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) {
try {
val writer = StringWriter()
throwable.printStackTrace(PrintWriter(writer))
File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString())
val metadata = JSONObject().apply {
put("source", "Application")
put("crashedAt", formatTimestampISO8601(Date()))
put("exceptionName", throwable.javaClass.name)
put("exceptionReason", throwable.message ?: "")
put("processName", Application.application.packageName)
put("appVersion", BuildConfig.VERSION_CODE.toString())
put("appMarketingVersion", BuildConfig.VERSION_NAME)
runCatching {
put("coreVersion", Libbox.version())
put("goVersion", Libbox.goVersion())
}
}
File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString())
} catch (_: Throwable) {
}
}
suspend fun refresh() = withContext(Dispatchers.IO) {
val reports = scanCrashReports()
_reports.value = reports
_unreadCount.value = reports.count { !it.isRead }
}
private fun archivePendingJvmCrashReport() {
val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME)
val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME)
val configFile = File(baseDir, CONFIG_FILE_NAME)
if (!crashFile.exists()) return
val content = crashFile.readText().trim()
if (content.isEmpty()) {
crashFile.delete()
metadataFile.delete()
configFile.delete()
return
}
val crashDate = Date(crashFile.lastModified())
val reportDir = nextAvailableReportDir(crashDate)
reportDir.mkdirs()
crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true)
crashFile.delete()
if (metadataFile.exists()) {
metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true)
metadataFile.delete()
}
if (configFile.exists()) {
val configContent = runCatching { configFile.readText() }.getOrNull()?.trim()
if (!configContent.isNullOrEmpty()) {
configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true)
}
configFile.delete()
}
}
private fun scanCrashReports(): List<CrashReport> {
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
if (!crashReportsDir.isDirectory) return emptyList()
val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
return directories.mapNotNull { dir ->
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
CrashReport(
id = dir.name,
date = date,
directory = dir,
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
)
}.sortedByDescending { it.date }
}
fun availableFiles(report: CrashReport): List<CrashReportFile> {
val files = mutableListOf<CrashReportFile>()
val metadataFile = File(report.directory, METADATA_FILE_NAME)
if (metadataFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile))
}
val goLogFile = File(report.directory, GO_LOG_FILE_NAME)
if (goLogFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile))
}
val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME)
if (jvmLogFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile))
}
val configFile = File(report.directory, CONFIG_FILE_NAME)
if (configFile.exists()) {
files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile))
}
return files
}
fun loadFileContent(file: CrashReportFile): String {
if (!file.file.exists()) return ""
val content = file.file.readText()
if (file.kind == CrashReportFile.Kind.METADATA) {
return runCatching {
JSONObject(content).toString(2)
}.getOrDefault(content)
}
return content
}
fun markAsRead(report: CrashReport) {
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
val updated = _reports.value.map {
if (it.id == report.id) it.copy(isRead = true) else it
}
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) {
report.directory.deleteRecursively()
val updated = _reports.value.filter { it.id != report.id }
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun deleteAll() = withContext(Dispatchers.IO) {
File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively()
_reports.value = emptyList()
_unreadCount.value = 0
}
fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
report.directory.copyRecursively(strippedDir, overwrite = true)
File(strippedDir, READ_MARKER_FILE_NAME).delete()
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
zipFile
}
private fun nextAvailableReportDir(date: Date): File {
val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME)
val baseName = timestampFormat.format(date)
var index = 0
while (true) {
val suffix = if (index == 0) "" else "-$index"
val dir = File(crashReportsDir, baseName + suffix)
if (!dir.exists()) return dir
index++
}
}
private fun parseTimestamp(name: String): Date? {
val components = name.split("-")
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
components.dropLast(1).joinToString("-")
} else {
name
}
return try {
timestampFormat.parse(baseName)
} catch (_: ParseException) {
null
}
}
private fun formatTimestampISO8601(date: Date): String {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return format.format(date)
}
}

View File

@@ -13,11 +13,13 @@ import java.io.StringWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
object DebugInfoExporter { object DebugInfoExporter {
private const val TAG = "DebugInfoExporter" private const val TAG = "DebugInfoExporter"
private const val BUFFER_SIZE = 128 * 1024
fun export(context: Context, outputPath: String, packageName: String): String { fun export(context: Context, outputPath: String, packageName: String): String {
Log.i(TAG, "export start: output=$outputPath, package=$packageName") Log.i(TAG, "export start: output=$outputPath, package=$packageName")
@@ -94,43 +96,27 @@ object DebugInfoExporter {
private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int { private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
var count = 0 var count = 0
val roots = val root = File("/system/framework")
listOf( if (!root.isDirectory) return 0
File("/system/framework"),
File("/system_ext/framework"),
File("/product/framework"),
File("/vendor/framework"),
)
val targetFiles = setOf("framework.jar", "services.jar") val targetFiles = setOf("framework.jar", "services.jar")
for (root in roots) { val files = root.listFiles() ?: emptyArray()
if (!root.isDirectory) continue for (file in files) {
val destPrefix = "framework/${root.name}" if (!file.isFile) continue
val files = root.listFiles() ?: emptyArray() if (file.name !in targetFiles) continue
for (file in files) { if (addFileEntry(zip, file, "framework/${file.name}", warnings, noCompression = true)) {
if (!file.isFile) continue count++
if (file.name !in targetFiles) continue
if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) {
count++
}
} }
} }
return count return count
} }
private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int { private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList<String>): Int {
var count = 0 val file = File("/apex/com.android.tethering/javalib/service-connectivity.jar")
val tetheringApex = File("/apex/com.android.tethering/javalib") if (!file.isFile) {
if (!tetheringApex.isDirectory) return 0 warnings.add("missing file: ${file.path}")
val destPrefix = "framework/apex_com.android.tethering" return 0
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++
}
} }
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<String>, context: Context): Int { private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList<String>, context: Context): Int {
@@ -222,16 +208,22 @@ object DebugInfoExporter {
return count return count
} }
private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList<String>): Boolean { private fun addFileEntry(
zip: ZipOutputStream,
file: File,
entryName: String,
warnings: MutableList<String>,
noCompression: Boolean = false,
): Boolean {
if (!file.isFile) { if (!file.isFile) {
warnings.add("missing file: ${file.path}") warnings.add("missing file: ${file.path}")
return false return false
} }
try { try {
val entry = ZipEntry(entryName) if (noCompression) zip.setLevel(Deflater.NO_COMPRESSION)
zip.putNextEntry(entry) zip.putNextEntry(ZipEntry(entryName))
BufferedInputStream(FileInputStream(file)).use { input -> BufferedInputStream(FileInputStream(file)).use { input ->
val buffer = ByteArray(16 * 1024) val buffer = ByteArray(BUFFER_SIZE)
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read <= 0) break if (read <= 0) break
@@ -239,9 +231,11 @@ object DebugInfoExporter {
} }
} }
zip.closeEntry() zip.closeEntry()
if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION)
return true return true
} catch (e: Throwable) { } catch (e: Throwable) {
warnings.add("zip failed ${file.path}: ${e.message}") warnings.add("zip failed ${file.path}: ${e.message}")
if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION)
return false return false
} }
} }
@@ -263,11 +257,10 @@ object DebugInfoExporter {
command: List<String>, command: List<String>,
): CommandResult? = try { ): CommandResult? = try {
val process = ProcessBuilder(command).redirectErrorStream(true).start() val process = ProcessBuilder(command).redirectErrorStream(true).start()
val entry = ZipEntry(entryName) zip.putNextEntry(ZipEntry(entryName))
zip.putNextEntry(entry)
var bytes = 0L var bytes = 0L
process.inputStream.use { input -> process.inputStream.use { input ->
val buffer = ByteArray(16 * 1024) val buffer = ByteArray(BUFFER_SIZE)
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read <= 0) break if (read <= 0) break

View File

@@ -43,17 +43,20 @@ object DefaultNetworkMonitor {
private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
val listener = listener ?: return val listener = listener ?: return
if (newNetwork != null) { if (newNetwork != null) {
val interfaceName =
(Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName
for (times in 0 until 10) { for (times in 0 until 10) {
val linkProperties = Application.connectivity.getLinkProperties(newNetwork)
if (linkProperties == null) {
Thread.sleep(100)
continue
}
var interfaceIndex: Int var interfaceIndex: Int
try { try {
interfaceIndex = NetworkInterface.getByName(interfaceName).index interfaceIndex = NetworkInterface.getByName(linkProperties.interfaceName).index
} catch (e: Exception) { } catch (e: Exception) {
Thread.sleep(100) Thread.sleep(100)
continue continue
} }
listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, false, false)
} }
} else { } else {
listener.updateDefaultInterface("", -1, false, false) listener.updateDefaultInterface("", -1, false, false)

View File

@@ -23,8 +23,8 @@ object LocalResolver : LocalDNSTransport {
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
override fun exchange(ctx: ExchangeContext, message: ByteArray) { override fun exchange(ctx: ExchangeContext, message: ByteArray) {
val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface")
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val signal = CancellationSignal() val signal = CancellationSignal()
ctx.onCancel(signal::cancel) ctx.onCancel(signal::cancel)
@@ -63,8 +63,8 @@ object LocalResolver : LocalDNSTransport {
} }
override fun lookup(ctx: ExchangeContext, network: String, domain: String) { override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface")
return runBlocking { return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
suspendCoroutine { continuation -> suspendCoroutine { continuation ->
val signal = CancellationSignal() val signal = CancellationSignal()

View File

@@ -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<NeighborEntry> CREATOR =
new Creator<>() {
@Override
public NeighborEntry createFromParcel(Parcel in) {
return new NeighborEntry(in);
}
@Override
public NeighborEntry[] newArray(int size) {
return new NeighborEntry[size];
}
};
}

View File

@@ -0,0 +1,165 @@
package io.nekohasekai.sfa.bg
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class OOMReport(
val id: String,
val date: Date,
val directory: File,
val isRead: Boolean,
)
data class OOMReportFile(
val kind: Kind,
val displayName: String,
val file: File,
) {
enum class Kind {
METADATA,
CONFIG,
PROFILE,
}
}
object OOMReportManager {
private const val METADATA_FILE_NAME = "metadata.json"
private const val CONFIG_FILE_NAME = "configuration.json"
private const val CMDLINE_FILE_NAME = "cmdline"
private const val READ_MARKER_FILE_NAME = ".read"
private const val OOM_REPORTS_DIR_NAME = "oom_reports"
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private lateinit var workingDir: File
private val _reports = MutableStateFlow<List<OOMReport>>(emptyList())
val reports: StateFlow<List<OOMReport>> = _reports
private val _unreadCount = MutableStateFlow(0)
val unreadCount: StateFlow<Int> = _unreadCount
fun install(workingDir: File) {
this.workingDir = workingDir
}
suspend fun refresh() = withContext(Dispatchers.IO) {
val reports = scanReports()
_reports.value = reports
_unreadCount.value = reports.count { !it.isRead }
}
private fun scanReports(): List<OOMReport> {
val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME)
if (!reportsDir.isDirectory) return emptyList()
val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList()
return directories.mapNotNull { dir ->
val date = parseTimestamp(dir.name) ?: return@mapNotNull null
OOMReport(
id = dir.name,
date = date,
directory = dir,
isRead = File(dir, READ_MARKER_FILE_NAME).exists(),
)
}.sortedByDescending { it.date }
}
fun availableFiles(report: OOMReport): List<OOMReportFile> {
val files = mutableListOf<OOMReportFile>()
val metadataFile = File(report.directory, METADATA_FILE_NAME)
if (metadataFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile))
}
val configFile = File(report.directory, CONFIG_FILE_NAME)
if (configFile.exists()) {
files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile))
}
report.directory.listFiles()?.filter { file ->
file.isFile &&
file.name != METADATA_FILE_NAME &&
file.name != CONFIG_FILE_NAME &&
file.name != CMDLINE_FILE_NAME &&
file.name != READ_MARKER_FILE_NAME
}?.sortedBy { it.name }?.forEach { file ->
files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file))
}
return files
}
fun loadFileContent(file: OOMReportFile): String {
if (!file.file.exists()) return ""
val content = file.file.readText()
if (file.kind == OOMReportFile.Kind.METADATA) {
return runCatching {
JSONObject(content).toString(2)
}.getOrDefault(content)
}
return content
}
fun markAsRead(report: OOMReport) {
File(report.directory, READ_MARKER_FILE_NAME).createNewFile()
val updated = _reports.value.map {
if (it.id == report.id) it.copy(isRead = true) else it
}
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) {
report.directory.deleteRecursively()
val updated = _reports.value.filter { it.id != report.id }
_reports.value = updated
_unreadCount.value = updated.count { !it.isRead }
}
suspend fun deleteAll() = withContext(Dispatchers.IO) {
File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively()
_reports.value = emptyList()
_unreadCount.value = 0
}
fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists()
suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME)
cacheDir.mkdirs()
val zipFile = File(cacheDir, "${report.id}.zip")
zipFile.delete()
val strippedDir = File(cacheDir, report.id)
strippedDir.deleteRecursively()
report.directory.copyRecursively(strippedDir, overwrite = true)
File(strippedDir, READ_MARKER_FILE_NAME).delete()
if (!includeConfig) {
File(strippedDir, CONFIG_FILE_NAME).delete()
}
Libbox.createZipArchive(strippedDir.path, zipFile.path)
zipFile
}
private fun parseTimestamp(name: String): Date? {
val components = name.split("-")
val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) {
components.dropLast(1).joinToString("-")
} else {
name
}
return try {
timestampFormat.parse(baseName)
} catch (_: ParseException) {
null
}
}
}

View File

@@ -136,7 +136,7 @@ public class ParceledListSlice<T extends Parcelable> implements Parcelable {
new Parcelable.ClassLoaderCreator<ParceledListSlice>() { new Parcelable.ClassLoaderCreator<ParceledListSlice>() {
@Override @Override
public ParceledListSlice createFromParcel(Parcel in) { public ParceledListSlice createFromParcel(Parcel in) {
return new ParceledListSlice(in, null); return new ParceledListSlice(in, ParceledListSlice.class.getClassLoader());
} }
@Override @Override

View File

@@ -11,12 +11,16 @@ import io.nekohasekai.libbox.ConnectionOwner
import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.InterfaceUpdateListener
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LocalDNSTransport import io.nekohasekai.libbox.LocalDNSTransport
import io.nekohasekai.libbox.NeighborEntryIterator
import io.nekohasekai.libbox.NeighborUpdateListener
import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.NetworkInterfaceIterator
import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.StringIterator
import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.TunOptions
import io.nekohasekai.libbox.WIFIState import io.nekohasekai.libbox.WIFIState
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.InterfaceAddress import java.net.InterfaceAddress
@@ -24,8 +28,11 @@ import java.net.NetworkInterface
import java.security.KeyStore import java.security.KeyStore
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import io.nekohasekai.libbox.NeighborEntry as LibboxNeighborEntry
import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface
private var neighborCallback: INeighborTableCallback.Stub? = null
interface PlatformInterfaceWrapper : PlatformInterface { interface PlatformInterfaceWrapper : PlatformInterface {
override fun usePlatformAutoDetectInterfaceControl(): Boolean = true override fun usePlatformAutoDetectInterfaceControl(): Boolean = true
@@ -58,7 +65,7 @@ interface PlatformInterfaceWrapper : PlatformInterface {
val owner = ConnectionOwner() val owner = ConnectionOwner()
owner.userId = uid owner.userId = uid
owner.userName = packages?.firstOrNull() ?: "" owner.userName = packages?.firstOrNull() ?: ""
owner.androidPackageName = packages?.firstOrNull() ?: "" owner.setAndroidPackageNames(StringArray(packages?.toList()?.iterator() ?: emptyList<String>().iterator()))
return owner return owner
} catch (e: Exception) { } catch (e: Exception) {
Log.e("PlatformInterface", "getConnectionOwnerUid", e) Log.e("PlatformInterface", "getConnectionOwnerUid", e)
@@ -172,6 +179,49 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return StringArray(certificates.iterator()) 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<NeighborEntry>
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<LibboxNeighborEntry>) : NeighborEntryIterator {
override fun hasNext(): Boolean = iterator.hasNext()
override fun next(): LibboxNeighborEntry = iterator.next()
}
private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) : NetworkInterfaceIterator { private class InterfaceArray(private val iterator: Iterator<LibboxNetworkInterface>) : NetworkInterfaceIterator {
override fun hasNext(): Boolean = iterator.hasNext() override fun hasNext(): Boolean = iterator.hasNext()

View File

@@ -6,6 +6,7 @@ import android.content.ServiceConnection
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.IBinder import android.os.IBinder
import android.os.RemoteException import android.os.RemoteException
import androidx.core.content.ContextCompat
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
@@ -17,7 +18,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object RootClient { object RootClient {
init { init {
@@ -53,6 +56,10 @@ object RootClient {
suspend fun bindService(): IRootService = connectionMutex.withLock { suspend fun bindService(): IRootService = connectionMutex.withLock {
service?.let { return it } service?.let { return it }
if (Shell.isAppGrantedRoot() == false) {
throw IOException("permission denied")
}
return withContext(Dispatchers.Main) { return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val conn = object : ServiceConnection { val conn = object : ServiceConnection {
@@ -72,7 +79,30 @@ object RootClient {
} }
val intent = Intent(Application.application, RootServer::class.java) 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 { continuation.invokeOnCancellation {
RootService.unbind(conn) RootService.unbind(conn)
@@ -103,4 +133,21 @@ object RootClient {
throw e.rethrowFromSystemServer() 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()
}
}
} }

View File

@@ -2,15 +2,36 @@ package io.nekohasekai.sfa.bg
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.RemoteCallbackList
import android.util.Log
import com.topjohnwu.superuser.ipc.RootService 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.BuildConfig
import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils
import java.io.IOException import java.io.IOException
import java.lang.reflect.Proxy
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
class RootServer : RootService() { class RootServer : RootService() {
private val neighborCallbacks = RemoteCallbackList<INeighborTableCallback>()
private var neighborSubscription: NeighborSubscription? = null
private val hostnameByMAC = ConcurrentHashMap<String, String>()
@Volatile
private var lastNeighborEntries: List<Pair<String, String>>? = null
private var tetheringCallback: Any? = null
private var tetheringManager: Any? = null
private val binder = object : IRootService.Stub() { private val binder = object : IRootService.Stub() {
override fun destroy() { override fun destroy() {
stopSelf() stopSelf()
@@ -31,7 +52,174 @@ class RootServer : RootService() {
outputPath!!, outputPath!!,
BuildConfig.APPLICATION_ID, 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<Pair<String, String>>()
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<Pair<String, String>>) {
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 onBind(intent: Intent): IBinder = binder
override fun onDestroy() {
stopTetheringMonitor()
neighborSubscription?.close()
neighborSubscription = null
neighborCallbacks.kill()
super.onDestroy()
}
} }

View File

@@ -7,6 +7,7 @@ import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.Notification
import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.TunOptions
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
@@ -66,6 +67,10 @@ class VPNService :
builder.setMetered(false) builder.setMetered(false)
} }
if (Settings.allowBypass) {
builder.allowBypass()
}
val inet4Address = options.inet4Address val inet4Address = options.inet4Address
while (inet4Address.hasNext()) { while (inet4Address.hasNext()) {
val address = inet4Address.next() val address = inet4Address.next()
@@ -79,7 +84,12 @@ class VPNService :
} }
if (options.autoRoute) { 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val inet4RouteAddress = options.inet4RouteAddress val inet4RouteAddress = options.inet4RouteAddress

View File

@@ -8,6 +8,7 @@ import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -42,6 +43,7 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@@ -85,6 +87,9 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R 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.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.bg.ServiceNotification
import io.nekohasekai.sfa.compat.WindowSizeClassCompat 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.GroupsCard
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
import io.nekohasekai.sfa.compose.screen.log.LogViewModel 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.theme.SFATheme
import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.LocalTopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController
import io.nekohasekai.sfa.compose.topbar.TopBarEntry import io.nekohasekai.sfa.compose.topbar.TopBarEntry
import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Alert
import io.nekohasekai.sfa.constant.ServiceMode import io.nekohasekai.sfa.constant.ServiceMode
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
@@ -123,6 +130,7 @@ import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -225,6 +233,10 @@ class MainActivity :
pendingNavigationRoute.value = "settings/privilege" pendingNavigationRoute.value = "settings/privilege"
} }
val uri = intent.data ?: return 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") { if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") {
try { try {
val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) val profile = Libbox.parseRemoteProfileImportLink(uri.toString())
@@ -320,6 +332,89 @@ class MainActivity :
// Snackbar state // Snackbar state
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var pendingApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
var activeApplyServiceChangeMode by remember { mutableStateOf<UiEvent.ApplyServiceChange.Mode?>(null) }
var applyServiceChangeJob by remember { mutableStateOf<Job?>(null) }
fun mergeApplyServiceChangeMode(
current: UiEvent.ApplyServiceChange.Mode?,
incoming: UiEvent.ApplyServiceChange.Mode,
): UiEvent.ApplyServiceChange.Mode = when {
current == UiEvent.ApplyServiceChange.Mode.Restart ||
incoming == UiEvent.ApplyServiceChange.Mode.Restart -> {
UiEvent.ApplyServiceChange.Mode.Restart
}
else -> incoming
}
fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) {
if (currentServiceStatus != Status.Started) {
return
}
pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode)
val activeMode = activeApplyServiceChangeMode
if (activeMode != null &&
mergeApplyServiceChangeMode(activeMode, mode) != activeMode
) {
snackbarHostState.currentSnackbarData?.dismiss()
}
if (applyServiceChangeJob?.isActive == true) {
return
}
applyServiceChangeJob =
scope.launch {
while (true) {
val modeToShow = pendingApplyServiceChangeMode ?: break
pendingApplyServiceChangeMode = null
activeApplyServiceChangeMode = modeToShow
val (message, actionLabel) =
when (modeToShow) {
UiEvent.ApplyServiceChange.Mode.Reload -> {
getString(R.string.service_reload_required) to
getString(R.string.action_reload)
}
UiEvent.ApplyServiceChange.Mode.Restart -> {
getString(R.string.service_restart_required) to
getString(R.string.action_restart)
}
}
val result =
snackbarHostState.showSnackbar(
message = message,
actionLabel = actionLabel,
duration = androidx.compose.material3.SnackbarDuration.Short,
)
activeApplyServiceChangeMode = null
if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
try {
when (modeToShow) {
UiEvent.ApplyServiceChange.Mode.Reload -> {
withContext(Dispatchers.IO) {
Libbox.newStandaloneCommandClient().serviceReload()
}
}
UiEvent.ApplyServiceChange.Mode.Restart -> {
restartServiceForApplyChange()
}
}
} catch (e: Exception) {
errorMessage = e.message ?: e.toString()
showErrorDialog = true
}
}
}
}
}
// Groups Sheet state // Groups Sheet state
var showGroupsSheet by remember { mutableStateOf(false) } var showGroupsSheet by remember { mutableStateOf(false) }
@@ -328,8 +423,6 @@ class MainActivity :
var showConnectionsSheet by remember { mutableStateOf(false) } var showConnectionsSheet by remember { mutableStateOf(false) }
// Error dialog state for UiEvent.ShowError // Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
val pendingIntentError = pendingIntentErrorMessage val pendingIntentError = pendingIntentErrorMessage
LaunchedEffect(pendingIntentError) { LaunchedEffect(pendingIntentError) {
if (pendingIntentError != null) { if (pendingIntentError != null) {
@@ -565,10 +658,22 @@ class MainActivity :
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} else { } else {
Row(verticalAlignment = Alignment.CenterVertically) { val progress by UpdateState.downloadProgress
CircularProgressIndicator(modifier = Modifier.size(24.dp)) Column {
Spacer(modifier = Modifier.width(12.dp)) if (progress != null) {
Text(stringResource(R.string.downloading)) 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 downloadJob = null
showDownloadDialog = false showDownloadDialog = false
downloadError = null downloadError = null
UpdateState.downloadProgress.value = null
}, },
) { ) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) 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 dashboardUiState by dashboardViewModel.uiState.collectAsState()
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
val isToolsSubScreen = currentRoute?.startsWith("tools/") == true
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
val isProfileRoute = currentRoute?.startsWith("profile/") == true val isProfileRoute = currentRoute?.startsWith("profile/") == true
val currentRootRoute = val currentRootRoute =
when { when {
isSettingsSubScreen -> Screen.Settings.route isSettingsSubScreen -> Screen.Settings.route
isToolsSubScreen -> Screen.Tools.route
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
isProfileRoute -> Screen.Dashboard.route isProfileRoute -> Screen.Dashboard.route
@@ -610,7 +718,7 @@ class MainActivity :
val isGroupsRoute = currentRootRoute == Screen.Groups.route val isGroupsRoute = currentRootRoute == Screen.Groups.route
val isLogRoute = currentRootRoute == Screen.Log.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 // Get LogViewModel instance if we're on the Log screen
val logViewModel: LogViewModel? = val logViewModel: LogViewModel? =
if (isLogRoute) { if (isLogRoute) {
@@ -640,6 +748,14 @@ class MainActivity :
null null
} }
val isToolsRoute = currentRootRoute == Screen.Tools.route
val tailscaleStatusViewModel: TailscaleStatusViewModel? =
if (isToolsRoute) {
viewModel()
} else {
null
}
val showGroupsInNav = dashboardUiState.hasGroups val showGroupsInNav = dashboardUiState.hasGroups
val showConnectionsInNav = val showConnectionsInNav =
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
@@ -654,6 +770,7 @@ class MainActivity :
add(Screen.Connections) add(Screen.Connections)
} }
add(Screen.Log) add(Screen.Log)
add(Screen.Tools)
add(Screen.Settings) add(Screen.Settings)
} }
@@ -661,6 +778,7 @@ class MainActivity :
buildSet { buildSet {
add(Screen.Dashboard.route) add(Screen.Dashboard.route)
add(Screen.Log.route) add(Screen.Log.route)
add(Screen.Tools.route)
add(Screen.Settings.route) add(Screen.Settings.route)
if (useNavigationRail && showGroupsInNav) { if (useNavigationRail && showGroupsInNav) {
add(Screen.Groups.route) add(Screen.Groups.route)
@@ -719,24 +837,7 @@ class MainActivity :
} }
} }
is UiEvent.RestartToTakeEffect -> { is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode)
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()
}
}
}
}
}
} }
} }
} }
@@ -769,6 +870,7 @@ class MainActivity :
logViewModel = logViewModel, logViewModel = logViewModel,
groupsViewModel = groupsViewModel, groupsViewModel = groupsViewModel,
connectionsViewModel = connectionsViewModel, connectionsViewModel = connectionsViewModel,
tailscaleStatusViewModel = tailscaleStatusViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
if (!useNavigationRail) { 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) { CompositionLocalProvider(LocalTopBarController provides topBarController) {
if (useNavigationRail) { if (useNavigationRail) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
@@ -916,6 +1029,10 @@ class MainActivity :
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null) 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 { } else {
Icon(screen.icon, contentDescription = null) Icon(screen.icon, contentDescription = null)
} }
@@ -960,6 +1077,10 @@ class MainActivity :
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null) 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 { } else {
Icon(screen.icon, contentDescription = null) Icon(screen.icon, contentDescription = null)
} }
@@ -1088,6 +1209,10 @@ class MainActivity :
} }
} }
BackHandler(enabled = selectedConnectionId != null) {
selectedConnectionId = null
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showConnectionsSheet = false showConnectionsSheet = false
@@ -1168,6 +1293,30 @@ class MainActivity :
showBackgroundLocationDialog = true 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() { override fun onDestroy() {
connection.disconnect() connection.disconnect()
super.onDestroy() super.onDestroy()

View File

@@ -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))
}
}
}

View File

@@ -19,7 +19,12 @@ sealed class UiEvent {
object RequestReconnectService : UiEvent() object RequestReconnectService : UiEvent()
object RestartToTakeEffect : UiEvent() data class ApplyServiceChange(val mode: Mode) : UiEvent() {
enum class Mode {
Reload,
Restart,
}
}
} }
/** /**

View File

@@ -6,7 +6,7 @@ import io.nekohasekai.libbox.Connection as LibboxConnection
import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo
@Immutable @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<String>) {
companion object { companion object {
fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { fun from(processInfo: LibboxProcessInfo?): ProcessInfo? {
if (processInfo == null) return null if (processInfo == null) return null
@@ -15,7 +15,7 @@ data class ProcessInfo(val processId: Long, val userId: Int, val userName: Strin
userId = processInfo.userID, userId = processInfo.userID,
userName = processInfo.userName ?: "", userName = processInfo.userName ?: "",
processPath = processInfo.processPath ?: "", processPath = processInfo.processPath ?: "",
packageName = processInfo.packageName ?: "", packageNames = processInfo.packageNames()?.toList() ?: emptyList(),
) )
} }
} }
@@ -66,7 +66,7 @@ data class Connection(
domain.contains(content, ignoreCase = true) || domain.contains(content, ignoreCase = true) ||
outbound.contains(content, ignoreCase = true) || outbound.contains(content, ignoreCase = true) ||
rule.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) { private fun performSearchType(type: String, value: String): Boolean = when (type) {
"network" -> network.equals(value, ignoreCase = true) "network" -> network.equals(value, ignoreCase = true)
@@ -79,7 +79,7 @@ data class Connection(
"rule" -> rule.contains(value, ignoreCase = true) "rule" -> rule.contains(value, ignoreCase = true)
"protocol" -> protocolName.equals(value, ignoreCase = true) "protocol" -> protocolName.equals(value, ignoreCase = true)
"user" -> user.contains(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) } "chain" -> chain.any { it.contains(value, ignoreCase = true) }
else -> false else -> false
} }

View File

@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.nekohasekai.sfa.R 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, icon = Icons.Default.SwapVert,
) )
object Tools : Screen(
route = "tools",
titleRes = R.string.title_tools,
icon = Icons.Default.Terminal,
)
object Settings : Screen( object Settings : Screen(
route = "settings", route = "settings",
titleRes = R.string.title_settings, titleRes = R.string.title_settings,
@@ -46,5 +53,6 @@ val bottomNavigationScreens =
listOf( listOf(
Screen.Dashboard, Screen.Dashboard,
Screen.Log, Screen.Log,
Screen.Tools,
Screen.Settings, Screen.Settings,
) )

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.profileoverride.PerAppProxyScreen
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen 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.PrivilegeSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen 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 import io.nekohasekai.sfa.constant.Status
private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = {
@@ -63,6 +80,7 @@ fun SFANavHost(
logViewModel: LogViewModel? = null, logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null, groupsViewModel: GroupsViewModel? = null,
connectionsViewModel: ConnectionsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null,
tailscaleStatusViewModel: TailscaleStatusViewModel? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( 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) { composable(Screen.Settings.route) {
SettingsScreen(navController = navController) SettingsScreen(navController = navController)
} }
@@ -221,7 +407,17 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft, popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight, 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( composable(
@@ -241,7 +437,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft, popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight, popExitTransition = slideOutToRight,
) { ) {
ServiceSettingsScreen(navController = navController) ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus)
} }
composable( composable(
@@ -251,7 +447,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft, popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight, popExitTransition = slideOutToRight,
) { ) {
ProfileOverrideScreen(navController = navController) ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus)
} }
composable( composable(
@@ -261,7 +457,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft, popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight, popExitTransition = slideOutToRight,
) { ) {
PerAppProxyScreen(onBack = { navController.navigateUp() }) PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
} }
composable( composable(
@@ -281,7 +477,7 @@ fun SFANavHost(
popEnterTransition = slideInFromLeft, popEnterTransition = slideInFromLeft,
popExitTransition = slideOutToRight, popExitTransition = slideOutToRight,
) { ) {
PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus)
} }
composable( composable(

View File

@@ -241,8 +241,9 @@ class ProfileImportHandler(private val context: Context) {
} }
// Save config file // Save config file
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } 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) configFile.writeText(content.config)
typedProfile.path = configFile.path typedProfile.path = configFile.path
@@ -268,8 +269,9 @@ class ProfileImportHandler(private val context: Context) {
} }
// Create empty config file for remote profile // Create empty config file for remote profile
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
val configFile = File(configDirectory, "${profile.userOrder}.json") val configFile = File(configDirectory, "$fileID.json")
configFile.writeText("{}") configFile.writeText("{}")
typedProfile.path = configFile.path typedProfile.path = configFile.path
@@ -370,8 +372,9 @@ class ProfileImportHandler(private val context: Context) {
} }
// Save the configuration file // Save the configuration file
val fileID = ProfileManager.nextFileID()
val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } 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) configFile.writeText(jsonContent)
typedProfile.path = configFile.path typedProfile.path = configFile.path

View File

@@ -247,7 +247,7 @@ fun ConnectionDetailsScreen(
} }
connection.processInfo?.let { processInfo -> connection.processInfo?.let { processInfo ->
if (processInfo.packageName.isNotEmpty() || if (processInfo.packageNames.isNotEmpty() ||
processInfo.processPath.isNotEmpty() || processInfo.processPath.isNotEmpty() ||
processInfo.processId > 0 processInfo.processId > 0
) { ) {
@@ -282,10 +282,10 @@ fun ConnectionDetailsScreen(
monospace = true, monospace = true,
) )
} }
if (processInfo.packageName.isNotEmpty()) { if (processInfo.packageNames.isNotEmpty()) {
DetailRow( DetailRow(
label = stringResource(R.string.connection_package_name), label = stringResource(R.string.connection_package_name),
value = processInfo.packageName, value = processInfo.packageNames.joinToString(", "),
monospace = true, monospace = true,
) )
} }

View File

@@ -82,7 +82,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? {
@Composable @Composable
fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) {
var showContextMenu by remember { mutableStateOf(false) } 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) } val appInfo = packageName?.let { rememberAppInfo(it) }
Box(modifier = modifier) { Box(modifier = modifier) {

View File

@@ -200,7 +200,7 @@ class DashboardViewModel :
private fun checkDeprecatedNotes() { private fun checkDeprecatedNotes() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { runCatching {
// Check if deprecated warnings are disabled // Check if deprecated warnings are disabled
if (Settings.disableDeprecatedWarnings) { if (Settings.disableDeprecatedWarnings) {
return@launch return@launch
@@ -227,8 +227,6 @@ class DashboardViewModel :
} }
} }
} }
} catch (e: Exception) {
sendError(e)
} }
} }
} }

View File

@@ -42,7 +42,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.graphics.lerp
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -52,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource

View File

@@ -19,8 +19,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow

View File

@@ -81,15 +81,19 @@ class LogViewModel :
override fun setDefaultLogLevel(level: Int) { override fun setDefaultLogLevel(level: Int) {
val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level")
_uiState.update { it.copy(defaultLogLevel = logLevel) } viewModelScope.launch(Dispatchers.Main) {
updateDisplayedLogs() _uiState.update { it.copy(defaultLogLevel = logLevel) }
updateDisplayedLogs()
}
} }
override fun clearLogs() { override fun clearLogs() {
allLogs.clear() viewModelScope.launch(Dispatchers.Main) {
bufferedLogs.clear() allLogs.clear()
_uiState.update { it.copy(isPaused = false) } bufferedLogs.clear()
updateDisplayedLogs() _uiState.update { it.copy(isPaused = false) }
updateDisplayedLogs()
}
} }
override fun requestClearLogs() { override fun requestClearLogs() {
@@ -104,23 +108,25 @@ class LogViewModel :
override fun appendLogs(message: List<LogEntry>) { override fun appendLogs(message: List<LogEntry>) {
val processedLogs = message.map { processLogEntry(it) } val processedLogs = message.map { processLogEntry(it) }
if (_uiState.value.isPaused) { viewModelScope.launch(Dispatchers.Main) {
bufferedLogs.addAll(processedLogs) if (_uiState.value.isPaused) {
} else { bufferedLogs.addAll(processedLogs)
val totalSize = allLogs.size + processedLogs.size } else {
val removeCount = (totalSize - maxLines).coerceAtLeast(0) val totalSize = allLogs.size + processedLogs.size
val removeCount = (totalSize - maxLines).coerceAtLeast(0)
if (removeCount > 0) { if (removeCount > 0) {
repeat(removeCount) { repeat(removeCount) {
allLogs.removeFirst() allLogs.removeFirst()
}
} }
}
allLogs.addAll(processedLogs) allLogs.addAll(processedLogs)
updateDisplayedLogs() updateDisplayedLogs()
if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) {
scrollToBottom() scrollToBottom()
}
} }
} }
} }

View File

@@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R 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.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.utils.PrivilegeSettingsClient import io.nekohasekai.sfa.utils.PrivilegeSettingsClient
@@ -95,10 +98,14 @@ private enum class RiskCategory {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { fun PrivilegeSettingsManageScreen(
onBack: () -> Unit,
serviceStatus: Status = Status.Stopped,
) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortMode by remember { mutableStateOf(SortMode.NAME) }
var sortReverse by remember { mutableStateOf(false) } var sortReverse by remember { mutableStateOf(false) }
@@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) {
} }
if (failure != null) { if (failure != null) {
syncErrorMessage = failure.message ?: failure.toString() syncErrorMessage = failure.message ?: failure.toString()
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} }
} }

View File

@@ -92,12 +92,11 @@ fun EditProfileContentScreen(
profileId: Long, profileId: Long,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
profileName: String = "",
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
) { ) {
val viewModel: EditProfileContentViewModel = val viewModel: EditProfileContentViewModel =
viewModel( viewModel(
factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly), factory = EditProfileContentViewModel.Factory(profileId, isReadOnly),
) )
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current

View File

@@ -38,11 +38,10 @@ data class EditProfileContentUiState(
val profileName: String = "", // Add profile name 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 = private val _uiState =
MutableStateFlow( MutableStateFlow(
EditProfileContentUiState( EditProfileContentUiState(
profileName = initialProfileName,
isReadOnly = initialIsReadOnly, isReadOnly = initialIsReadOnly,
), ),
) )
@@ -211,7 +210,7 @@ class EditProfileContentViewModel(private val profileId: Long, initialProfileNam
originalContent = content, originalContent = content,
hasUnsavedChanges = false, hasUnsavedChanges = false,
isLoading = 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( class Factory(
private val profileId: Long, private val profileId: Long,
private val initialProfileName: String = "",
private val initialIsReadOnly: Boolean = false, private val initialIsReadOnly: Boolean = false,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) {
return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T return EditProfileContentViewModel(profileId, initialIsReadOnly) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }

View File

@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.compose.screen.profile package io.nekohasekai.sfa.compose.screen.profile
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -64,12 +65,12 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
profileId = profileId, profileId = profileId,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToIconSelection = { currentIconId -> onNavigateToIconSelection = { currentIconId ->
navController.navigate("icon_selection/${currentIconId ?: "null"}") { navController.navigate("icon_selection/${Uri.encode(currentIconId ?: "null")}") {
launchSingleTop = true launchSingleTop = true
} }
}, },
onNavigateToEditContent = { profileName, isReadOnly -> onNavigateToEditContent = { isReadOnly ->
navController.navigate("edit_content/$profileName/$isReadOnly") { navController.navigate("edit_content/$isReadOnly") {
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -128,13 +129,9 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
} }
composable( composable(
route = "edit_content/{profileName}/{isReadOnly}", route = "edit_content/{isReadOnly}",
arguments = arguments =
listOf( listOf(
navArgument("profileName") {
type = NavType.StringType
defaultValue = ""
},
navArgument("isReadOnly") { navArgument("isReadOnly") {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false defaultValue = false
@@ -165,7 +162,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
) )
}, },
) { backStackEntry -> ) { backStackEntry ->
val profileName = backStackEntry.arguments?.getString("profileName") ?: ""
val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false
EditProfileContentScreen( EditProfileContentScreen(
@@ -173,7 +169,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi
onNavigateBack = { onNavigateBack = {
navController.popBackStack("edit_profile", inclusive = false) navController.popBackStack("edit_profile", inclusive = false)
}, },
profileName = profileName,
isReadOnly = isReadOnly, isReadOnly = isReadOnly,
) )
} }

View File

@@ -79,7 +79,7 @@ fun EditProfileScreen(
profileId: Long, profileId: Long,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, onNavigateToIconSelection: (currentIconId: String?) -> Unit = {},
onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> }, onNavigateToEditContent: (isReadOnly: Boolean) -> Unit = {},
viewModel: EditProfileViewModel = viewModel(), viewModel: EditProfileViewModel = viewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -473,7 +473,6 @@ fun EditProfileScreen(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
onNavigateToEditContent( onNavigateToEditContent(
uiState.name,
uiState.profileType == TypedProfile.Type.Remote, uiState.profileType == TypedProfile.Type.Remote,
) )
}, },

View File

@@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R 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.AppSelectionCard
import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.PackageCache
import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.SortMode
import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.shared.buildDisplayPackages
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
@@ -106,10 +109,14 @@ private sealed class ScanResult {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PerAppProxyScreen(onBack: () -> Unit) { fun PerAppProxyScreen(
onBack: () -> Unit,
serviceStatus: Status = Status.Stopped,
) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) }
var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortMode by remember { mutableStateOf(SortMode.NAME) }
@@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) {
fun saveSelectedApplications(newUids: Set<Int>) { fun saveSelectedApplications(newUids: Set<Int>) {
coroutineScope.launch { 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 -> onModeChange = { mode ->
proxyMode = mode proxyMode = mode
coroutineScope.launch { coroutineScope.launch {
Settings.perAppProxyMode = mode withContext(Dispatchers.IO) {
Settings.perAppProxyMode = mode
}
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
}, },
onSortModeChange = { mode -> onSortModeChange = { mode ->

View File

@@ -7,10 +7,15 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -25,8 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.AdminPanelSettings
import androidx.compose.material.icons.outlined.Autorenew 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.Download
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Language 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -62,6 +73,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -71,13 +83,19 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R 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.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.utils.HookStatusClient import io.nekohasekai.sfa.utils.HookStatusClient
@@ -88,12 +106,16 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.Locale import java.util.Locale
import android.provider.Settings as AndroidSettings import android.provider.Settings as AndroidSettings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun AppSettingsScreen(navController: NavController) { fun AppSettingsScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
) {
OverrideTopBar { OverrideTopBar {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.title_app_settings)) }, title = { Text(stringResource(R.string.title_app_settings)) },
@@ -113,10 +135,12 @@ fun AppSettingsScreen(navController: NavController) {
val hasUpdate by UpdateState.hasUpdate val hasUpdate by UpdateState.hasUpdate
val updateInfo by UpdateState.updateInfo val updateInfo by UpdateState.updateInfo
val isChecking by UpdateState.isChecking 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 showTrackDialog by remember { mutableStateOf(false) }
var currentTrack by remember { mutableStateOf(Settings.updateTrack) } var currentTrack by remember { mutableStateOf(Settings.updateTrack) }
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
var showErrorDialog by remember { mutableStateOf<Int?>(null) } var showErrorDialog by remember { mutableStateOf<String?>(null) }
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) }
@@ -132,10 +156,12 @@ fun AppSettingsScreen(navController: NavController) {
var downloadJob by remember { mutableStateOf<Job?>(null) } var downloadJob by remember { mutableStateOf<Job?>(null) }
var downloadError by remember { mutableStateOf<String?>(null) } var downloadError by remember { mutableStateOf<String?>(null) }
var showUpdateAvailableDialog by remember { mutableStateOf(false) } var showUpdateAvailableDialog by remember { mutableStateOf(false) }
var showVersionMenu by remember { mutableStateOf(false) }
var notificationEnabled by remember { mutableStateOf(true) } var notificationEnabled by remember { mutableStateOf(true) }
var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) }
var showDisableNotificationDialog by remember { mutableStateOf(false) } var showDisableNotificationDialog by remember { mutableStateOf(false) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var showLanguageDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) }
val availableLocales = remember { getSupportedLocales(context) } val availableLocales = remember { getSupportedLocales(context) }
@@ -144,8 +170,22 @@ fun AppSettingsScreen(navController: NavController) {
mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) 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) { LaunchedEffect(Unit) {
HookStatusClient.refresh() HookStatusClient.refresh()
refreshCacheSize()
} }
// Re-check states when returning from background (e.g., after granting permission) // 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) { if (showTrackDialog) {
UpdateTrackDialog( UpdateTrackDialog(
currentTrack = currentTrack, currentTrack = currentTrack,
@@ -198,11 +253,11 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
showErrorDialog?.let { messageRes -> showErrorDialog?.let { message ->
AlertDialog( AlertDialog(
onDismissRequest = { showErrorDialog = null }, onDismissRequest = { showErrorDialog = null },
title = { Text(stringResource(R.string.check_update)) }, title = { Text(stringResource(R.string.check_update)) },
text = { Text(stringResource(messageRes)) }, text = { Text(message) },
confirmButton = { confirmButton = {
TextButton(onClick = { showErrorDialog = null }) { TextButton(onClick = { showErrorDialog = null }) {
Text(stringResource(R.string.ok)) Text(stringResource(R.string.ok))
@@ -223,10 +278,22 @@ fun AppSettingsScreen(navController: NavController) {
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} else { } else {
Row(verticalAlignment = Alignment.CenterVertically) { val progress by UpdateState.downloadProgress
CircularProgressIndicator(modifier = Modifier.size(24.dp)) Column {
Spacer(modifier = Modifier.width(12.dp)) if (progress != null) {
Text(stringResource(R.string.downloading)) 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 downloadJob = null
showDownloadDialog = false showDownloadDialog = false
downloadError = null downloadError = null
UpdateState.downloadProgress.value = null
}, },
) { ) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
@@ -381,39 +449,70 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) { ) {
Column { Column {
ListItem( Box {
headlineContent = { ListItem(
Text( headlineContent = {
stringResource(R.string.app_version_title), Text(
style = MaterialTheme.typography.bodyLarge, stringResource(R.string.app_version_title),
) style = MaterialTheme.typography.bodyLarge,
}, )
supportingContent = { },
Text( supportingContent = {
BuildConfig.VERSION_NAME, Text(
style = MaterialTheme.typography.bodyMedium, BuildConfig.VERSION_NAME,
) style = MaterialTheme.typography.bodyMedium,
}, )
leadingContent = { },
Icon( leadingContent = {
imageVector = Icons.Outlined.Info, Icon(
contentDescription = null, imageVector = Icons.Outlined.Info,
tint = MaterialTheme.colorScheme.primary, contentDescription = null,
) tint = MaterialTheme.colorScheme.primary,
}, )
trailingContent = { },
if (hasUpdate) { trailingContent = {
Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } 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( ListItem(
headlineContent = { headlineContent = {
@@ -440,13 +539,80 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { showLanguageDialog = true }, .clickable { showLanguageDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, 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 dynamicNotification = checked
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.dynamicNotification = checked Settings.dynamicNotification = checked
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
} }
}, },
) )
@@ -555,14 +724,21 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) { ) {
Column { Column {
val isFDroid = UpdateSource.fromString(currentSource) == UpdateSource.FDROID
val updateItemCount = val updateItemCount =
run { run {
var count = 0 var count = 0
if (Vendor.supportsTrackSelection()) { if (Vendor.updateSources.size > 1) {
count += 1
}
if (Vendor.hasCustomUpdate) {
count += 1
}
if (isFDroid) {
count += 1 count += 1
} }
count += 1 count += 1
if (Vendor.supportsSilentInstall()) { if (Vendor.hasCustomUpdate) {
count += 1 count += 1
if (silentInstallEnabled) { if (silentInstallEnabled) {
count += 1 count += 1
@@ -574,7 +750,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
} }
if (Vendor.supportsAutoUpdate()) { if (Vendor.hasCustomUpdate) {
count += 1 count += 1
} }
count 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( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -601,9 +809,13 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
supportingContent = { supportingContent = {
val trackName = when (UpdateTrack.fromString(currentTrack)) { val trackName = if (isFDroid) {
UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) stringResource(R.string.update_track_stable)
UpdateTrack.BETA -> stringResource(R.string.update_track_beta) } 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) Text(trackName, style = MaterialTheme.typography.bodyMedium)
}, },
@@ -615,8 +827,63 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = 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() updateItemModifier()
.clickable { showTrackDialog = true }, .clickable { navController.navigate("settings/fdroid_mirror") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -656,7 +923,7 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) )
if (Vendor.supportsSilentInstall()) { if (Vendor.hasCustomUpdate) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -836,7 +1103,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
if (Vendor.supportsAutoUpdate()) { if (Vendor.hasCustomUpdate) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -940,15 +1207,17 @@ fun AppSettingsScreen(navController: NavController) {
val result = Vendor.checkUpdateAsync() val result = Vendor.checkUpdateAsync()
UpdateState.setUpdate(result) UpdateState.setUpdate(result)
if (result == null) { if (result == null) {
showErrorDialog = R.string.no_updates_available showErrorDialog = context.getString(R.string.no_updates_available)
} else { } else {
showUpdateAvailableDialog = true showUpdateAvailableDialog = true
} }
} catch (_: UpdateCheckException.TrackNotSupported) { } catch (_: UpdateCheckException.TrackNotSupported) {
UpdateState.setUpdate(null) UpdateState.setUpdate(null)
showErrorDialog = R.string.update_track_not_supported showErrorDialog = context.getString(R.string.update_track_not_supported)
} catch (_: Exception) { } catch (e: Exception) {
Log.e("AppSettingsScreen", "checkUpdateAsync failed", e)
UpdateState.setUpdate(null) UpdateState.setUpdate(null)
showErrorDialog = e.message
} }
} }
UpdateState.isChecking.value = false 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 @Composable
private fun UpdateTrackDialog( private fun UpdateTrackDialog(
currentTrack: String, 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<Locale> { private fun getSupportedLocales(context: Context): List<Locale> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val localeConfig = LocaleConfig(context) val localeConfig = LocaleConfig(context)

View File

@@ -5,8 +5,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.DeleteForever
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Info 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.material.icons.outlined.WarningAmber
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -41,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -52,11 +59,12 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.clipboardText
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun CoreSettingsScreen(navController: NavController) { fun CoreSettingsScreen(navController: NavController) {
OverrideTopBar { OverrideTopBar {
@@ -77,6 +85,7 @@ fun CoreSettingsScreen(navController: NavController) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dataSize by remember { mutableStateOf("") } var dataSize by remember { mutableStateOf("") }
val version = remember { Libbox.version() } val version = remember { Libbox.version() }
var showVersionMenu by remember { mutableStateOf(false) }
var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) }
// Calculate data size on launch // Calculate data size on launch
@@ -114,34 +123,66 @@ fun CoreSettingsScreen(navController: NavController) {
) { ) {
Column { Column {
// Version Info // Version Info
ListItem( Box {
headlineContent = { ListItem(
Text( headlineContent = {
stringResource(R.string.core_version_title), Text(
style = MaterialTheme.typography.bodyLarge, stringResource(R.string.core_version_title),
) style = MaterialTheme.typography.bodyLarge,
}, )
supportingContent = { },
Text( supportingContent = {
version, Text(
style = MaterialTheme.typography.bodyMedium, version,
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant,
) modifier = Modifier.padding(top = 4.dp),
}, )
leadingContent = { },
Icon( leadingContent = {
imageVector = Icons.Outlined.Info, Icon(
contentDescription = null, imageVector = Icons.Outlined.Info,
tint = MaterialTheme.colorScheme.primary, contentDescription = null,
) tint = MaterialTheme.colorScheme.primary,
}, )
modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), },
colors = modifier = Modifier
ListItemDefaults.colors( .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
containerColor = Color.Transparent, .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 // Data Size
ListItem( ListItem(
@@ -181,57 +222,58 @@ fun CoreSettingsScreen(navController: NavController) {
} }
} }
// Options Section if (version.contains("-")) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(R.string.options), text = stringResource(R.string.beta_settings),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), 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,
),
) )
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 // Working Directory Section

View File

@@ -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<String, Int>() }
val latencyErrors = remember { mutableStateMapOf<String, Boolean>() }
var showAddForm by remember { mutableStateOf(false) }
var newMirrorName by remember { mutableStateOf("") }
var newMirrorUrl by remember { mutableStateOf("") }
var urlError by remember { mutableStateOf<String?>(null) }
val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url)
var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) }
val builtinMirrors = remember {
val mirrors = mutableListOf<MirrorEntry>()
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<String, Int>,
latencyErrors: Map<String, Boolean>,
) {
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 -> {}
}
}

View File

@@ -62,9 +62,9 @@ import androidx.core.content.FileProvider
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R 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.SelectableMessageDialog
import io.nekohasekai.sfa.compose.base.UiEvent 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.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
@@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
val systemHookStatus by HookStatusClient.status.collectAsState() val systemHookStatus by HookStatusClient.status.collectAsState()
var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) }
@@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title) messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString() messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true showMessageDialog = true
} else if (serviceStatus == Status.Started) { } else {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} }
}, },
@@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title) messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString() messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true showMessageDialog = true
} else if (checked && serviceStatus == Status.Started) { } else {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} }
}, },
@@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status
messageDialogTitle = context.getString(R.string.error_title) messageDialogTitle = context.getString(R.string.error_title)
messageDialogMessage = failure.message ?: failure.toString() messageDialogMessage = failure.message ?: failure.toString()
showMessageDialog = true showMessageDialog = true
} else if (serviceStatus == Status.Started) { } else {
GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} }
}, },

View File

@@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.RootClient 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.screen.profileoverride.PerAppProxyScanner
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.vendor.PackageQueryManager import io.nekohasekai.sfa.vendor.PackageQueryManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProfileOverrideScreen(navController: NavController) { fun ProfileOverrideScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
) {
OverrideTopBar { OverrideTopBar {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.profile_override)) }, title = { Text(stringResource(R.string.profile_override)) },
@@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) {
var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) }
var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) }
var isScanning by remember { mutableStateOf(false) } var isScanning by remember { mutableStateOf(false) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
fun scanAndSaveManagedList() { fun scanAndSaveManagedList(shouldNotify: Boolean = false) {
isScanning = true isScanning = true
scope.launch { scope.launch {
val chinaApps = PerAppProxyScanner.scanAllChinaApps() val chinaApps = PerAppProxyScanner.scanAllChinaApps()
@@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyManagedList = chinaApps Settings.perAppProxyManagedList = chinaApps
} }
isScanning = false isScanning = false
if (shouldNotify) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
} }
} }
@@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = true Settings.perAppProxyEnabled = true
} }
if (managedModeEnabled) { if (managedModeEnabled) {
scanAndSaveManagedList() scanAndSaveManagedList(shouldNotify = true)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} }
} }
@@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Settings.autoRedirect = true Settings.autoRedirect = true
} }
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} else { } else {
Toast.makeText( Toast.makeText(
context, context,
@@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) {
autoRedirect = false autoRedirect = false
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.autoRedirect = false Settings.autoRedirect = false
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
} }
} }
}, },
@@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) {
perAppProxyEnabled = checked perAppProxyEnabled = checked
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.perAppProxyEnabled = checked Settings.perAppProxyEnabled = checked
if (!checked || !managedModeEnabled) {
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
} }
if (checked && managedModeEnabled) { if (checked && managedModeEnabled) {
scanAndSaveManagedList() scanAndSaveManagedList(shouldNotify = true)
} }
} }
}, },
@@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.perAppProxyManagedMode = true Settings.perAppProxyManagedMode = true
} }
scanAndSaveManagedList() scanAndSaveManagedList(shouldNotify = true)
} else { } else {
managedModeEnabled = false managedModeEnabled = false
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.perAppProxyManagedMode = false Settings.perAppProxyManagedMode = false
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
} }
} }
}, },
@@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) {
perAppProxyEnabled = true perAppProxyEnabled = true
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.perAppProxyEnabled = true Settings.perAppProxyEnabled = true
if (!managedModeEnabled) {
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
}
}
} }
if (managedModeEnabled) { if (managedModeEnabled) {
scanAndSaveManagedList() scanAndSaveManagedList(shouldNotify = true)
} }
}, },
) { ) {
@@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = true Settings.perAppProxyEnabled = true
} }
if (managedModeEnabled) { if (managedModeEnabled) {
scanAndSaveManagedList() scanAndSaveManagedList(shouldNotify = true)
} else {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
} }
} else { } else {
showRootDialog = false showRootDialog = false
@@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) {
Settings.perAppProxyEnabled = false Settings.perAppProxyEnabled = false
} }
} }
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
showModeDialog = false showModeDialog = false
}, },
colors = ListItemDefaults.colors( colors = ListItemDefaults.colors(
@@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT
} }
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload)
showModeDialog = false showModeDialog = false
}, },
colors = ListItemDefaults.colors( colors = ListItemDefaults.colors(

View File

@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -35,22 +40,40 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection 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.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.launchCustomTab import io.nekohasekai.sfa.ktx.launchCustomTab
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { fun ServiceSettingsScreen(
navController: NavController,
serviceConnection: ServiceConnection? = null,
serviceStatus: Status = Status.Stopped,
) {
OverrideTopBar { OverrideTopBar {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.service)) }, title = { Text(stringResource(R.string.service)) },
@@ -66,14 +89,14 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
} }
val context = LocalContext.current val context = LocalContext.current
// Check battery optimization status val scope = rememberCoroutineScope()
var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } 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 = val requestBatteryOptimizationLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
) { _ -> ) { _ ->
// Recheck the status after returning from settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
isBatteryOptimizationIgnored = isBatteryOptimizationIgnored =
@@ -81,7 +104,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
} }
} }
// Check battery optimization status on launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
@@ -100,7 +122,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .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) { if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card( Card(
modifier = 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)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
private const val ALLOW_BYPASS_DOC_URL =
"https://developer.android.com/reference/android/net/VpnService.Builder#allowBypass()"

View File

@@ -1,7 +1,5 @@
package io.nekohasekai.sfa.compose.screen.settings package io.nekohasekai.sfa.compose.screen.settings
import android.os.Build
import android.os.PowerManager
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -37,10 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -70,15 +65,8 @@ fun SettingsScreen(navController: NavController) {
val hookStatus by HookStatusClient.status.collectAsState() val hookStatus by HookStatusClient.status.collectAsState()
val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus)
val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus)
var isBatteryOptimizationIgnored by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
HookStatusClient.refresh() 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( Column(
@@ -167,11 +155,6 @@ fun SettingsScreen(navController: NavController) {
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
}, },
trailingContent = {
if (!isBatteryOptimizationIgnored) {
Badge(containerColor = MaterialTheme.colorScheme.primary)
}
},
modifier = Modifier.clickable { navController.navigate("settings/service") }, modifier = Modifier.clickable { navController.navigate("settings/service") },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(

View File

@@ -0,0 +1,459 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.CrashReport
import io.nekohasekai.sfa.bg.CrashReportFile
import io.nekohasekai.sfa.bg.CrashReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportDetailScreen(navController: NavController, reportId: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<CrashReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
files = CrashReportManager.availableFiles(report)
}
CrashReportManager.markAsRead(report)
}
isLoading = false
}
val title = if (report != null) {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
} else {
reportId
}
val hasConfig = report != null && CrashReportManager.hasConfigFile(report)
fun shareReport(includeConfig: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
}
OverrideTopBar {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
}
IconButton(onClick = {
scope.launch {
if (report != null) {
CrashReportManager.delete(report)
}
navController.navigateUp()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (files.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
stringResource(R.string.report_section_files),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
files.forEachIndexed { index, file ->
val shape = when {
files.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == files.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
val icon = when (file.kind) {
CrashReportFile.Kind.METADATA -> Icons.Default.DataObject
CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal
CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport
CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings
}
ListItem(
headlineContent = {
Text(
file.displayName,
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.clickable {
if (file.kind == CrashReportFile.Kind.METADATA) {
navController.navigate("tools/crash_report/$reportId/metadata")
} else {
navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportMetadataScreen(navController: NavController, reportId: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
entries = loadMetadataEntries(report)
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.report_metadata)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
entries.forEachIndexed { index, (key, value) ->
val shape = when {
entries.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
)
index == entries.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
key,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(value)
},
modifier = Modifier.clip(shape),
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
}
}
}
}
private fun loadMetadataEntries(report: CrashReport): List<Pair<String, String>> {
val metadataFile = CrashReportManager.availableFiles(report)
.find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList()
val content = metadataFile.file.readText()
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
return json.keys().asSequence()
.mapNotNull { key ->
val value = json.optString(key, "")
if (value.isNotBlank()) key to value else null
}
.toList()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
val reports by CrashReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var content by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(true) }
val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull()
val displayName = when (kind) {
CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log)
CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log)
CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration)
else -> fileKind
}
LaunchedEffect(report, kind) {
if (report != null && kind != null) {
withContext(Dispatchers.IO) {
val file = CrashReportManager.availableFiles(report).find { it.kind == kind }
content = if (file != null) CrashReportManager.loadFileContent(file) else ""
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(displayName) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (content.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Text(
text = content,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
}
}
}

View File

@@ -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)

View File

@@ -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<String, Color> = 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))
}
},
)
}

View File

@@ -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<NetworkQualityState, Nothing>() {
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)
}
}
}
}
}
}

View File

@@ -0,0 +1,451 @@
package io.nekohasekai.sfa.compose.screen.tools
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavController
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.OOMReport
import io.nekohasekai.sfa.bg.OOMReportFile
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.DateFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportDetailScreen(navController: NavController, reportId: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var files by remember { mutableStateOf<List<OOMReportFile>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var shareMenuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
files = OOMReportManager.availableFiles(report)
}
OOMReportManager.markAsRead(report)
}
isLoading = false
}
val title = if (report != null) {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
} else {
reportId
}
val hasConfig = report != null && OOMReportManager.hasConfigFile(report)
fun shareReport(includeConfig: Boolean) {
val currentReport = report ?: return
scope.launch {
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null))
}
}
OverrideTopBar {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (!isLoading && files.isNotEmpty()) {
if (hasConfig) {
IconButton(onClick = { shareMenuExpanded = true }) {
Icon(Icons.Default.Share, contentDescription = null)
}
DropdownMenu(
expanded = shareMenuExpanded,
onDismissRequest = { shareMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.report_share_with_config)) },
onClick = {
shareMenuExpanded = false
shareReport(includeConfig = true)
},
)
}
} else {
IconButton(onClick = { shareReport(includeConfig = false) }) {
Icon(Icons.Default.Share, contentDescription = null)
}
}
IconButton(onClick = {
scope.launch {
if (report != null) {
OOMReportManager.delete(report)
}
navController.navigateUp()
}
}) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (files.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Text(
stringResource(R.string.report_section_files),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
files.forEachIndexed { index, file ->
val shape = when {
files.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == files.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
val icon = when (file.kind) {
OOMReportFile.Kind.METADATA -> Icons.Default.DataObject
OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings
OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal
}
ListItem(
headlineContent = {
Text(
file.displayName,
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier = Modifier
.clip(shape)
.then(
if (file.kind != OOMReportFile.Kind.PROFILE) {
Modifier.clickable {
if (file.kind == OOMReportFile.Kind.METADATA) {
navController.navigate("tools/oom_report/$reportId/metadata")
} else {
navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}")
}
}
} else {
Modifier
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportMetadataScreen(navController: NavController, reportId: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(report) {
if (report != null) {
withContext(Dispatchers.IO) {
entries = loadOOMMetadataEntries(report)
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.report_metadata)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
entries.forEachIndexed { index, (key, value) ->
val shape = when {
entries.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == entries.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(key, style = MaterialTheme.typography.bodyLarge)
},
supportingContent = { Text(value) },
modifier = Modifier.clip(shape),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
}
private fun loadOOMMetadataEntries(report: OOMReport): List<Pair<String, String>> {
val metadataFile = OOMReportManager.availableFiles(report)
.find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList()
val content = metadataFile.file.readText()
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
return json.keys().asSequence()
.mapNotNull { key ->
val value = json.optString(key, "")
if (value.isNotBlank()) key to value else null
}
.toList()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
val reports by OOMReportManager.reports.collectAsState()
val report = reports.find { it.id == reportId }
var content by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf(fileKind) }
var isLoading by remember { mutableStateOf(true) }
val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull()
LaunchedEffect(report, kind) {
if (report != null && kind != null) {
withContext(Dispatchers.IO) {
val file = OOMReportManager.availableFiles(report).find { it.kind == kind }
if (file != null) {
displayName = file.displayName
content = OOMReportManager.loadFileContent(file)
}
}
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(displayName) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else if (content.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Text(
text = content,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
}
}
}

View File

@@ -0,0 +1,416 @@
package io.nekohasekai.sfa.compose.screen.tools
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.OOMReportManager
import io.nekohasekai.sfa.compose.base.UiEvent
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OOMReportListScreen(
navController: NavController,
serviceStatus: Status = Status.Stopped,
) {
val reports by OOMReportManager.reports.collectAsState()
var isLoading by remember { mutableStateOf(true) }
var menuExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var errorMessage by remember { mutableStateOf<String?>(null) }
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) }
var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) }
var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) }
var showMemoryLimitDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
OOMReportManager.refresh()
val storedLimit = Settings.oomMemoryLimitMB
if (!memoryLimitOptions.contains(storedLimit)) {
oomMemoryLimitMB = memoryLimitOptions.first()
Settings.oomMemoryLimitMB = oomMemoryLimitMB
}
isLoading = false
}
OverrideTopBar {
TopAppBar(
title = { Text(stringResource(R.string.oom_report)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.oom_report_fetch)) },
leadingIcon = {
Icon(Icons.Outlined.Memory, contentDescription = null)
},
onClick = {
menuExpanded = false
if (serviceStatus != Status.Started) {
errorMessage =
Application.application.getString(R.string.service_not_started)
} else {
scope.launch {
val failure =
withContext(Dispatchers.IO) {
runCatching {
Libbox.newStandaloneCommandClient().triggerOOMReport()
}.exceptionOrNull()
}
if (failure != null) {
errorMessage = failure.message ?: failure.toString()
} else {
delay(1000)
withContext(Dispatchers.IO) {
OOMReportManager.refresh()
}
}
}
}
},
)
if (reports.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.report_delete_all),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Outlined.DeleteSweep,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuExpanded = false
scope.launch { OOMReportManager.deleteAll() }
},
)
}
}
},
)
}
Box(modifier = Modifier.fillMaxSize()) {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// Reports section
Text(
stringResource(R.string.report_section_reports),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
if (reports.isEmpty()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.report_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
} else {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
reports.forEachIndexed { index, report ->
val shape = when {
reports.size == 1 -> RoundedCornerShape(12.dp)
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
index == reports.lastIndex -> RoundedCornerShape(
bottomStart = 12.dp,
bottomEnd = 12.dp,
)
else -> RoundedCornerShape(0.dp)
}
ListItem(
headlineContent = {
Text(
formatDate(report.date),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
)
},
leadingContent = if (!report.isRead) {
{
Badge(
containerColor = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate("tools/oom_report/${report.id}")
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
Text(
stringResource(R.string.oom_report_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
// Settings section
Text(
stringResource(R.string.title_settings),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp),
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_enable_memory_limit),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(stringResource(R.string.oom_report_enable_memory_limit_description))
},
trailingContent = {
Switch(
checked = oomKillerEnabled,
onCheckedChange = { checked ->
oomKillerEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.oomKillerEnabled = checked
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
AnimatedVisibility(visible = oomKillerEnabled) {
Column {
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_memory_limit),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L))
},
modifier = Modifier.clickable { showMemoryLimitDialog = true },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
ListItem(
headlineContent = {
Text(
stringResource(R.string.oom_report_kill_connections),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(stringResource(R.string.oom_report_kill_connections_description))
},
trailingContent = {
Switch(
checked = oomKillerKillConnections,
onCheckedChange = { checked ->
oomKillerKillConnections = checked
scope.launch(Dispatchers.IO) {
Settings.oomKillerDisabled = !checked
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
}
}
}
}
}
errorMessage?.let { message ->
AlertDialog(
onDismissRequest = { errorMessage = null },
confirmButton = {
TextButton(onClick = { errorMessage = null }) {
Text(stringResource(android.R.string.ok))
}
},
text = { Text(message) },
)
}
if (showMemoryLimitDialog) {
AlertDialog(
onDismissRequest = { showMemoryLimitDialog = false },
title = { Text(stringResource(R.string.oom_report_memory_limit)) },
text = {
Column {
memoryLimitOptions.forEach { value ->
ListItem(
headlineContent = {
Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L))
},
leadingContent = {
RadioButton(
selected = value == oomMemoryLimitMB,
onClick = null,
)
},
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable {
oomMemoryLimitMB = value
showMemoryLimitDialog = false
scope.launch(Dispatchers.IO) {
Settings.oomMemoryLimitMB = value
Application.application.reloadSetupOptions()
withContext(Dispatchers.Main) {
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
}
},
confirmButton = {},
)
}
}
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)

View File

@@ -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<List<GroupItem>>(emptyList())
val outbounds: StateFlow<List<GroupItem>> = _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<io.nekohasekai.libbox.OutboundGroupItem>) {
_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
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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))
}
},
)
}

View File

@@ -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<STUNTestState, Nothing>() {
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)
}
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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<String?>(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,
)
}
}
}
}

View File

@@ -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<Float> = emptyList(),
)
class TailscalePingViewModel : BaseViewModel<TailscalePingState, Nothing>() {
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()
}
}

View File

@@ -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<String>,
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<TailscalePeerData>,
)
data class TailscaleEndpointData(
val endpointTag: String,
val backendState: String,
val authURL: String,
val networkName: String,
val magicDNSSuffix: String,
val selfPeer: TailscalePeerData?,
val userGroups: List<TailscaleUserGroupData>,
)
data class TailscaleStatusState(
val endpoints: List<TailscaleEndpointData> = emptyList(),
val isSubscribed: Boolean = false,
)
class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>() {
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<TailscaleEndpointData> {
val endpoints = mutableListOf<TailscaleEndpointData>()
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<TailscaleUserGroupData>()
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<TailscalePeerData>()
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<String>()
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,
)
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -5,7 +5,10 @@ object SettingsKey {
const val SERVICE_MODE = "service_mode" const val SERVICE_MODE = "service_mode"
const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val CHECK_UPDATE_ENABLED = "check_update_enabled"
const val UPDATE_CHECK_PROMPTED = "update_check_prompted" const val UPDATE_CHECK_PROMPTED = "update_check_prompted"
const val UPDATE_SOURCE = "update_source"
const val UPDATE_TRACK = "update_track" 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_ENABLED = "silent_install_enabled"
const val SILENT_INSTALL_METHOD = "silent_install_method" const val SILENT_INSTALL_METHOD = "silent_install_method"
const val AUTO_UPDATE_ENABLED = "auto_update_enabled" 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_MANAGED_LIST = "per_app_proxy_managed_list"
const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" 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 SYSTEM_PROXY_ENABLED = "system_proxy_enabled"
const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_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_RENAME_ENABLED = "hide_settings_interface_rename_enabled"
const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" 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 // dashboard
const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"

View File

@@ -41,6 +41,7 @@ object Settings {
var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL }
var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) 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 checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false }
var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false }
var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) {
@@ -62,6 +63,8 @@ object Settings {
"SHIZUKU" "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 autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false }
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false }
@@ -93,6 +96,7 @@ object Settings {
perAppProxyList perAppProxyList
} }
var allowBypass by dataStore.boolean(SettingsKey.ALLOW_BYPASS) { false }
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false }
@@ -102,6 +106,10 @@ object Settings {
) { false } ) { false }
var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } 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 dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }

View File

@@ -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,
)
}

View File

@@ -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
}
}
}

View File

@@ -11,6 +11,7 @@ object UpdateState {
val isChecking = mutableStateOf(false) val isChecking = mutableStateOf(false)
val isDownloading = mutableStateOf(false) val isDownloading = mutableStateOf(false)
val downloadProgress = mutableStateOf<Float?>(null)
val downloadError = mutableStateOf<String?>(null) val downloadError = mutableStateOf<String?>(null)
val cachedApkFile = mutableStateOf<File?>(null) val cachedApkFile = mutableStateOf<File?>(null)
@@ -38,6 +39,7 @@ object UpdateState {
hasUpdate.value = false hasUpdate.value = false
updateInfo.value = null updateInfo.value = null
isDownloading.value = false isDownloading.value = false
downloadProgress.value = null
downloadError.value = null downloadError.value = null
installStatus.value = InstallStatus.Idle installStatus.value = InstallStatus.Idle
cachedApkFile.value = null cachedApkFile.value = null
@@ -46,6 +48,7 @@ object UpdateState {
fun resetDownload() { fun resetDownload() {
isDownloading.value = false isDownloading.value = false
downloadProgress.value = null
downloadError.value = null downloadError.value = null
} }

View File

@@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.LogIterator
import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItemIterator
import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.OutboundGroupIterator
import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.StringIterator
@@ -29,6 +30,7 @@ open class CommandClient(
private val additionalHandlers = mutableListOf<Handler>() private val additionalHandlers = mutableListOf<Handler>()
private var cachedGroups: MutableList<OutboundGroup>? = null private var cachedGroups: MutableList<OutboundGroup>? = null
private var cachedOutbounds: List<io.nekohasekai.libbox.OutboundGroupItem>? = null
fun addHandler(handler: Handler) { fun addHandler(handler: Handler) {
synchronized(additionalHandlers) { synchronized(additionalHandlers) {
@@ -37,6 +39,9 @@ open class CommandClient(
cachedGroups?.let { groups -> cachedGroups?.let { groups ->
handler.updateGroups(groups) handler.updateGroups(groups)
} }
cachedOutbounds?.let { outbounds ->
handler.updateOutbounds(outbounds)
}
} }
} }
} }
@@ -57,6 +62,7 @@ open class CommandClient(
Log, Log,
ClashMode, ClashMode,
Connections, Connections,
Outbounds,
} }
interface Handler { interface Handler {
@@ -74,6 +80,8 @@ open class CommandClient(
fun updateGroups(newGroups: MutableList<OutboundGroup>) {} fun updateGroups(newGroups: MutableList<OutboundGroup>) {}
fun updateOutbounds(outbounds: List<io.nekohasekai.libbox.OutboundGroupItem>) {}
fun initializeClashMode(modeList: List<String>, currentMode: String) {} fun initializeClashMode(modeList: List<String>, currentMode: String) {}
fun updateClashMode(newMode: String) {} fun updateClashMode(newMode: String) {}
@@ -95,12 +103,18 @@ open class CommandClient(
ConnectionType.Log -> Libbox.CommandLog ConnectionType.Log -> Libbox.CommandLog
ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.ClashMode -> Libbox.CommandClashMode
ConnectionType.Connections -> Libbox.CommandConnections ConnectionType.Connections -> Libbox.CommandConnections
ConnectionType.Outbounds -> Libbox.CommandOutbounds
} }
options.addCommand(command) options.addCommand(command)
} }
options.statusInterval = 1 * 1000 * 1000 * 1000 options.statusInterval = 1 * 1000 * 1000 * 1000
val commandClient = CommandClient(clientHandler, options) val commandClient = CommandClient(clientHandler, options)
commandClient.connect() try {
commandClient.connect()
} catch (e: Exception) {
Log.d("CommandClient", "connect failed", e)
return
}
this.commandClient = commandClient this.commandClient = commandClient
} }
@@ -137,6 +151,18 @@ open class CommandClient(
getAllHandlers().forEach { it.updateGroups(groups) } getAllHandlers().forEach { it.updateGroups(groups) }
} }
override fun writeOutbounds(message: OutboundGroupItemIterator?) {
if (message == null) {
return
}
val outbounds = mutableListOf<io.nekohasekai.libbox.OutboundGroupItem>()
while (message.hasNext()) {
outbounds.add(message.next())
}
cachedOutbounds = outbounds
getAllHandlers().forEach { it.updateOutbounds(outbounds) }
}
override fun setDefaultLogLevel(level: Int) { override fun setDefaultLogLevel(level: Int) {
getAllHandlers().forEach { it.setDefaultLogLevel(level) } getAllHandlers().forEach { it.setDefaultLogLevel(level) }
} }

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateSource
interface VendorInterface { interface VendorInterface {
fun checkUpdate(activity: Activity, byUser: Boolean) fun checkUpdate(activity: Activity, byUser: Boolean)
@@ -14,53 +15,17 @@ interface VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)? = null, onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
): ImageAnalysis.Analyzer? ): 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 fun isPerAppProxyAvailable(): Boolean = true
/** val hasCustomUpdate: Boolean get() = false
* Check if track selection is available (e.g., stable/beta)
* @return true if track selection is supported val updateSources: List<UpdateSource> get() = listOf(UpdateSource.GITHUB)
*/
fun supportsTrackSelection(): Boolean = false
/**
* Check for updates asynchronously
* @return UpdateInfo if update is available, null otherwise
*/
fun checkUpdateAsync(): UpdateInfo? = null 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() {} 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 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") suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor")
} }

View File

@@ -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
}
}

View File

@@ -1,54 +1,16 @@
package io.nekohasekai.sfa.xposed package io.nekohasekai.sfa.xposed
import android.content.Context
import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface 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) { 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) { override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) {
val systemContext = resolveSystemContext() HookInstaller.install(param.classLoader)
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,
)
}
}
} }
companion object { companion object {
const val TAG = "sing-box-lsposed" 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
}
} }

View File

@@ -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)
}
}

View File

@@ -6,6 +6,7 @@ import android.net.Network
import android.net.NetworkInfo import android.net.NetworkInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Parcel
import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import io.nekohasekai.sfa.xposed.HookErrorStore import io.nekohasekai.sfa.xposed.HookErrorStore
@@ -26,6 +27,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
private val hooked = AtomicBoolean(false) private val hooked = AtomicBoolean(false)
private val initializerHooked = AtomicBoolean(false) private val initializerHooked = AtomicBoolean(false)
private var classLoadUnhook: XC_MethodHook.Unhook? = null private var classLoadUnhook: XC_MethodHook.Unhook? = null
private var onTransactUnhook: XC_MethodHook.Unhook? = null
private val serviceManagerHooked = AtomicBoolean(false) private val serviceManagerHooked = AtomicBoolean(false)
private var connectivityClassLoader: ClassLoader = classLoader private var connectivityClassLoader: ClassLoader = classLoader
private val skipLogKeys = ConcurrentHashMap<String, Boolean>() private val skipLogKeys = ConcurrentHashMap<String, Boolean>()
@@ -53,6 +55,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
} }
hookConnectivityServiceInitializer() hookConnectivityServiceInitializer()
hookClassLoaderFallback() hookClassLoaderFallback()
hookOnTransactFallback()
tryHookFromServiceManager() tryHookFromServiceManager()
} }
@@ -148,12 +151,39 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
} }
} }
HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") 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 return null
} }
private fun hookConnectivityServiceInitializer() { private fun hookConnectivityServiceInitializer() {
if (sdkInt < 31 || sdkInt >= 33) { if (sdkInt < 31) {
HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (requires API 31+)")
return return
} }
val candidates = listOf( val candidates = listOf(
@@ -238,20 +268,20 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo
classLoadUnhook = null classLoadUnhook = null
return return
} }
when (name) { when {
"com.android.server.ConnectivityService" -> { name == "com.android.server.ConnectivityService" ||
name.endsWith(".com.android.server.ConnectivityService") -> {
val cls = param.result as? Class<*> ?: return val cls = param.result as? Class<*> ?: return
HookErrorStore.i( HookErrorStore.i(
SOURCE, SOURCE,
"ConnectivityService loaded via ${param.thisObject.javaClass.name}", "ConnectivityService loaded via ${param.thisObject.javaClass.name}: $name",
) )
installHooks(cls, "loadClass") installHooks(cls, "loadClass")
classLoadUnhook?.unhook() classLoadUnhook?.unhook()
classLoadUnhook = null classLoadUnhook = null
} }
"com.android.server.ConnectivityServiceInitializer", name == "com.android.server.ConnectivityServiceInitializer" ||
"com.android.server.ConnectivityServiceInitializerB", name == "com.android.server.ConnectivityServiceInitializerB" -> {
-> {
if (sdkInt < 31) return if (sdkInt < 31) return
if (initializerHooked.get()) return if (initializerHooked.get()) return
val cls = param.result as? Class<*> ?: 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<*>) { private fun hookConnectivityServiceInitializerClass(cls: Class<*>) {
if (sdkInt < 31) return if (sdkInt < 31) return
if (initializerHooked.get()) return if (initializerHooked.get()) return

View File

@@ -23,6 +23,8 @@
<string name="action">اقدام</string> <string name="action">اقدام</string>
<string name="action_start">شروع</string> <string name="action_start">شروع</string>
<string name="action_deselect">لغو انتخاب</string> <string name="action_deselect">لغو انتخاب</string>
<string name="action_reload">بارگذاری مجدد</string>
<string name="action_restart">راه‌اندازی مجدد</string>
<string name="expand">باز کردن</string> <string name="expand">باز کردن</string>
<string name="collapse">جمع کردن</string> <string name="collapse">جمع کردن</string>
<string name="expand_all">باز کردن همه</string> <string name="expand_all">باز کردن همه</string>
@@ -198,12 +200,18 @@
<string name="source_code">کد منبع</string> <string name="source_code">کد منبع</string>
<string name="sponsor">حامی مالی</string> <string name="sponsor">حامی مالی</string>
<string name="working_directory">پوشه کاری</string> <string name="working_directory">پوشه کاری</string>
<string name="beta_settings">تنظیمات بتا</string>
<string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string> <string name="disable_deprecated_warnings">غیرفعال‌کردن هشدارهای منسوخ</string>
<string name="cache_size">اندازه حافظه پنهان</string>
<string name="clear_cache">پاک‌سازی حافظه پنهان</string>
<string name="notification_settings">اعلان‌ها</string> <string name="notification_settings">اعلان‌ها</string>
<string name="enable_notification">فعال‌کردن اعلان</string> <string name="enable_notification">فعال‌کردن اعلان</string>
<string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string> <string name="dynamic_notification">نمایش سرعت بلادرنگ در اعلان</string>
<string name="disable_notification_description">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید.</string> <string name="disable_notification_description">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید.</string>
<string name="disable_notification_description_legacy">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید.</string> <string name="disable_notification_description_legacy">به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید.</string>
<string name="allow_bypass">اجازه دور زدن VPN</string>
<string name="allow_bypass_description">در صورت فعال بودن، برنامه‌ها می‌توانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند.</string>
<string name="android_documentation">مستندات Android</string>
<string name="auto_redirect">تغییر مسیر خودکار</string> <string name="auto_redirect">تغییر مسیر خودکار</string>
<string name="auto_redirect_description">نیازمند دسترسی ROOT</string> <string name="auto_redirect_description">نیازمند دسترسی ROOT</string>
<string name="system_http_proxy">پراکسی HTTP سیستم</string> <string name="system_http_proxy">پراکسی HTTP سیستم</string>
@@ -275,6 +283,22 @@
<string name="new_version_available">نسخه جدید موجود است: %s</string> <string name="new_version_available">نسخه جدید موجود است: %s</string>
<string name="auto_update">به‌روزرسانی خودکار</string> <string name="auto_update">به‌روزرسانی خودکار</string>
<string name="auto_update_description">دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه</string> <string name="auto_update_description">دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه</string>
<string name="update_source">منبع به‌روزرسانی</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">آینه F-Droid</string>
<string name="fdroid_mirror_test_all">انتخاب خودکار بر اساس تأخیر</string>
<string name="fdroid_mirror_testing">در حال تست…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">ناموفق</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">افزودن آینه</string>
<string name="fdroid_mirror_name_hint">نام</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">سفارشی</string>
<string name="fdroid_mirror_invalid_url">URL نامعتبر</string>
<string name="fdroid_mirror_add_action">افزودن</string>
<string name="fdroid_mirror_delete">حذف</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">نصب بی‌صدا</string> <string name="silent_install">نصب بی‌صدا</string>
@@ -402,6 +426,94 @@
<string name="content_description_collapse_search">جمع کردن جستجو</string> <string name="content_description_collapse_search">جمع کردن جستجو</string>
<string name="content_description_search_logs">جستجوی لاگ‌ها</string> <string name="content_description_search_logs">جستجوی لاگ‌ها</string>
<!-- Tools -->
<string name="title_tools">ابزارها</string>
<string name="title_network">شبکه</string>
<string name="network_quality">کیفیت شبکه</string>
<string name="network_quality_url">URL</string>
<string name="network_quality_serial">ترتیبی</string>
<string name="network_quality_http3">HTTP/3</string>
<string name="network_quality_max_runtime">حداکثر زمان اجرا</string>
<string name="network_quality_max_runtime_30s">30s</string>
<string name="network_quality_max_runtime_60s">60s</string>
<string name="network_quality_start">شروع تست</string>
<string name="network_quality_cancel">لغو تست</string>
<string name="network_quality_idle_latency">تأخیر بیکاری</string>
<string name="network_quality_download">دانلود</string>
<string name="network_quality_upload">آپلود</string>
<string name="network_quality_download_rpm">RPM دانلود</string>
<string name="network_quality_upload_rpm">RPM آپلود</string>
<string name="network_quality_confidence_high">اطمینان بالا</string>
<string name="network_quality_confidence_medium">اطمینان متوسط</string>
<string name="network_quality_confidence_low">اطمینان پایین</string>
<string name="network_quality_metered_title">اتصال محدود</string>
<string name="network_quality_metered_message">شما از اتصال محدود استفاده می‌کنید. این تست حجم قابل توجهی داده مصرف خواهد کرد.</string>
<string name="network_quality_metered_continue">ادامه</string>
<string name="tool_configuration">پیکربندی</string>
<string name="tool_results">نتایج</string>
<string name="tool_outbound">خروجی</string>
<string name="tool_default_outbound">پیش‌فرض</string>
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">نقاط اتصال</string>
<string name="tailscale_status">وضعیت</string>
<string name="tailscale_state">وضعیت</string>
<string name="tailscale_network">شبکه</string>
<string name="tailscale_open_auth_url">باز کردن لینک احراز هویت</string>
<string name="tailscale_open_auth_url_qr_code">نمایش QR کد لینک احراز هویت</string>
<string name="tailscale_this_device">این دستگاه</string>
<string name="tailscale_connected">متصل</string>
<string name="tailscale_not_connected">متصل نیست</string>
<string name="tailscale_addresses">آدرس‌های Tailscale</string>
<string name="tailscale_details">جزئیات</string>
<string name="tailscale_key_expiry">انقضای کلید</string>
<string name="tailscale_exit_node">گره خروجی</string>
<string name="tailscale_active">فعال</string>
<string name="stun_test">تست STUN</string>
<string name="stun_server">سرور</string>
<string name="stun_start">شروع تست</string>
<string name="stun_cancel">لغو تست</string>
<string name="stun_external_address">آدرس خارجی</string>
<string name="stun_latency">تأخیر</string>
<string name="stun_nat_mapping">نگاشت NAT</string>
<string name="stun_nat_filtering">فیلتر NAT</string>
<string name="stun_nat_type_detection">تشخیص نوع NAT</string>
<string name="stun_nat_not_supported">پشتیبانی نمی‌شود توسط سرور</string>
<!-- Shared Report -->
<string name="report_empty">خالی</string>
<string name="report_section_reports">گزارش‌ها</string>
<string name="report_section_files">فایل‌ها</string>
<string name="report_delete_all">حذف همه</string>
<string name="report_delete">حذف</string>
<string name="report_share">اشتراک‌گذاری</string>
<string name="report_share_with_config">اشتراک‌گذاری با پیکربندی</string>
<string name="report_metadata">فراداده</string>
<string name="report_configuration">پیکربندی</string>
<string name="report_origin_local">محلی</string>
<string name="service_not_started">سرویس شروع نشده است</string>
<string name="service_reload_required">برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است</string>
<string name="service_restart_required">برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است</string>
<!-- Crash Report -->
<string name="crash_report">گزارش خرابی</string>
<string name="crash_report_go_log">Go Crash Log</string>
<string name="crash_report_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">هنگام بروز خرابی گزارشی دریافت خواهید کرد.</string>
<!-- OOM Report -->
<string name="oom_report">گزارش کمبود حافظه</string>
<string name="oom_report_description">هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید.</string>
<string name="oom_report_fetch">دریافت گزارش حافظه</string>
<string name="oom_report_enable_memory_limit">فعال‌سازی محدودیت حافظه</string>
<string name="oom_report_enable_memory_limit_description">یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند.</string>
<string name="oom_report_memory_limit">محدودیت حافظه</string>
<string name="oom_report_kill_connections">قطع اتصالات</string>
<string name="oom_report_kill_connections_description">هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید.</string>
<!-- Xposed Module --> <!-- Xposed Module -->
<string name="xposed_description">بهبود دسترسی ویژه برای sing-box</string> <string name="xposed_description">بهبود دسترسی ویژه برای sing-box</string>

View File

@@ -23,6 +23,8 @@
<string name="action">Действие</string> <string name="action">Действие</string>
<string name="action_start">Начать</string> <string name="action_start">Начать</string>
<string name="action_deselect">Отменить выбор</string> <string name="action_deselect">Отменить выбор</string>
<string name="action_reload">Перезагрузить</string>
<string name="action_restart">Перезапустить</string>
<string name="expand">Развернуть</string> <string name="expand">Развернуть</string>
<string name="collapse">Свернуть</string> <string name="collapse">Свернуть</string>
<string name="expand_all">Развернуть все</string> <string name="expand_all">Развернуть все</string>
@@ -198,12 +200,18 @@
<string name="source_code">Исходный код</string> <string name="source_code">Исходный код</string>
<string name="sponsor">Поддержать</string> <string name="sponsor">Поддержать</string>
<string name="working_directory">Рабочая директория</string> <string name="working_directory">Рабочая директория</string>
<string name="beta_settings">Бета-настройки</string>
<string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string> <string name="disable_deprecated_warnings">Отключить предупреждения об устаревании</string>
<string name="cache_size">Размер кэша</string>
<string name="clear_cache">Очистить кэш</string>
<string name="notification_settings">Уведомления</string> <string name="notification_settings">Уведомления</string>
<string name="enable_notification">Включить уведомления</string> <string name="enable_notification">Включить уведомления</string>
<string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string> <string name="dynamic_notification">Отображать скорость в реальном времени в уведомлении</string>
<string name="disable_notification_description">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.</string> <string name="disable_notification_description">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках.</string>
<string name="disable_notification_description_legacy">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.</string> <string name="disable_notification_description_legacy">Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении.</string>
<string name="allow_bypass">Разрешить обход VPN</string>
<string name="allow_bypass_description">Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую.</string>
<string name="android_documentation">Документация Android</string>
<string name="auto_redirect">Автоматическое перенаправление</string> <string name="auto_redirect">Автоматическое перенаправление</string>
<string name="auto_redirect_description">Требуются права ROOT</string> <string name="auto_redirect_description">Требуются права ROOT</string>
<string name="system_http_proxy">Системный HTTP-прокси</string> <string name="system_http_proxy">Системный HTTP-прокси</string>
@@ -275,6 +283,22 @@
<string name="new_version_available">Доступна новая версия: %s</string> <string name="new_version_available">Доступна новая версия: %s</string>
<string name="auto_update">Автообновление</string> <string name="auto_update">Автообновление</string>
<string name="auto_update_description">Автоматически загружать и устанавливать обновления в фоне</string> <string name="auto_update_description">Автоматически загружать и устанавливать обновления в фоне</string>
<string name="update_source">Источник обновлений</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">Зеркало F-Droid</string>
<string name="fdroid_mirror_test_all">Автовыбор по задержке</string>
<string name="fdroid_mirror_testing">Тестирование…</string>
<string name="fdroid_mirror_latency">%d мс</string>
<string name="fdroid_mirror_failed">Ошибка</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">Добавить зеркало</string>
<string name="fdroid_mirror_name_hint">Имя</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">Пользовательское</string>
<string name="fdroid_mirror_invalid_url">Недопустимый URL</string>
<string name="fdroid_mirror_add_action">Добавить</string>
<string name="fdroid_mirror_delete">Удалить</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">Тихая установка</string> <string name="silent_install">Тихая установка</string>
@@ -408,6 +432,94 @@
<string name="content_description_collapse_search">Свернуть поиск</string> <string name="content_description_collapse_search">Свернуть поиск</string>
<string name="content_description_search_logs">Поиск в логе</string> <string name="content_description_search_logs">Поиск в логе</string>
<!-- Tools -->
<string name="title_tools">Инструменты</string>
<string name="title_network">Сеть</string>
<string name="network_quality">Качество сети</string>
<string name="network_quality_url">URL</string>
<string name="network_quality_serial">Последовательно</string>
<string name="network_quality_http3">HTTP/3</string>
<string name="network_quality_max_runtime">Макс. время</string>
<string name="network_quality_max_runtime_30s">30s</string>
<string name="network_quality_max_runtime_60s">60s</string>
<string name="network_quality_start">Начать тест</string>
<string name="network_quality_cancel">Остановить тест</string>
<string name="network_quality_idle_latency">Задержка в простое</string>
<string name="network_quality_download">Загрузка</string>
<string name="network_quality_upload">Отправка</string>
<string name="network_quality_download_rpm">Загрузка RPM</string>
<string name="network_quality_upload_rpm">Отправка RPM</string>
<string name="network_quality_confidence_high">Высокая уверенность</string>
<string name="network_quality_confidence_medium">Средняя уверенность</string>
<string name="network_quality_confidence_low">Низкая уверенность</string>
<string name="network_quality_metered_title">Лимитное подключение</string>
<string name="network_quality_metered_message">Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика.</string>
<string name="network_quality_metered_continue">Продолжить</string>
<string name="tool_configuration">Конфигурация</string>
<string name="tool_results">Результаты</string>
<string name="tool_outbound">Исходящий</string>
<string name="tool_default_outbound">По умолчанию</string>
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">Точки подключения</string>
<string name="tailscale_status">Статус</string>
<string name="tailscale_state">Состояние</string>
<string name="tailscale_network">Сеть</string>
<string name="tailscale_open_auth_url">Открыть URL авторизации</string>
<string name="tailscale_open_auth_url_qr_code">Показать QR-код авторизации</string>
<string name="tailscale_this_device">Это устройство</string>
<string name="tailscale_connected">Подключено</string>
<string name="tailscale_not_connected">Не подключено</string>
<string name="tailscale_addresses">Адреса Tailscale</string>
<string name="tailscale_details">Подробности</string>
<string name="tailscale_key_expiry">Срок действия ключа</string>
<string name="tailscale_exit_node">Выходной узел</string>
<string name="tailscale_active">Активен</string>
<string name="stun_test">STUN-тест</string>
<string name="stun_server">Сервер</string>
<string name="stun_start">Начать тест</string>
<string name="stun_cancel">Остановить тест</string>
<string name="stun_external_address">Внешний адрес</string>
<string name="stun_latency">Задержка</string>
<string name="stun_nat_mapping">NAT-отображение</string>
<string name="stun_nat_filtering">NAT-фильтрация</string>
<string name="stun_nat_type_detection">Определение типа NAT</string>
<string name="stun_nat_not_supported">Не поддерживается сервером</string>
<!-- Shared Report -->
<string name="report_empty">Пусто</string>
<string name="report_section_reports">Отчёты</string>
<string name="report_section_files">Файлы</string>
<string name="report_delete_all">Удалить все</string>
<string name="report_delete">Удалить</string>
<string name="report_share">Поделиться</string>
<string name="report_share_with_config">Поделиться с конфигурацией</string>
<string name="report_metadata">Метаданные</string>
<string name="report_configuration">Конфигурация</string>
<string name="report_origin_local">Локальный</string>
<string name="service_not_started">Служба не запущена</string>
<string name="service_reload_required">Для применения изменений необходимо перезагрузить сервис</string>
<string name="service_restart_required">Для применения изменений необходимо перезапустить сервис</string>
<!-- Crash Report -->
<string name="crash_report">Отчёт о сбое</string>
<string name="crash_report_go_log">Go Crash Log</string>
<string name="crash_report_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">Вы получите отчёт при возникновении сбоя.</string>
<!-- OOM Report -->
<string name="oom_report">Отчёт о нехватке памяти</string>
<string name="oom_report_description">При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта.</string>
<string name="oom_report_fetch">Получить отчёт о памяти</string>
<string name="oom_report_enable_memory_limit">Включить ограничение памяти</string>
<string name="oom_report_enable_memory_limit_description">Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения.</string>
<string name="oom_report_memory_limit">Ограничение памяти</string>
<string name="oom_report_kill_connections">Завершить соединения</string>
<string name="oom_report_kill_connections_description">Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса.</string>
<!-- Xposed Module --> <!-- Xposed Module -->
<string name="xposed_description">Привилегированное расширение для sing-box</string> <string name="xposed_description">Привилегированное расширение для sing-box</string>

View File

@@ -23,6 +23,8 @@
<string name="action">操作</string> <string name="action">操作</string>
<string name="action_start">启动</string> <string name="action_start">启动</string>
<string name="action_deselect">取消选择</string> <string name="action_deselect">取消选择</string>
<string name="action_reload">重载</string>
<string name="action_restart">重启</string>
<string name="expand">展开</string> <string name="expand">展开</string>
<string name="collapse">收起</string> <string name="collapse">收起</string>
<string name="expand_all">全部展开</string> <string name="expand_all">全部展开</string>
@@ -66,7 +68,7 @@
<string name="status_started">已启动</string> <string name="status_started">已启动</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_items">仪表项</string> <string name="dashboard_items">仪表项</string>
<string name="memory">内存</string> <string name="memory">内存</string>
<string name="goroutines">协程</string> <string name="goroutines">协程</string>
<string name="upload">上传</string> <string name="upload">上传</string>
@@ -86,7 +88,7 @@
<string name="search_connections">搜索连接…</string> <string name="search_connections">搜索连接…</string>
<string name="close_connections_confirm">关闭所有连接?</string> <string name="close_connections_confirm">关闭所有连接?</string>
<string name="connection_state_all">全部</string> <string name="connection_state_all">全部</string>
<string name="connection_state_active"></string> <string name="connection_state_active"></string>
<string name="connection_state_closed">已关闭</string> <string name="connection_state_closed">已关闭</string>
<string name="connection_sort_date">日期</string> <string name="connection_sort_date">日期</string>
<string name="connection_sort_traffic">流量</string> <string name="connection_sort_traffic">流量</string>
@@ -198,12 +200,18 @@
<string name="source_code">源代码</string> <string name="source_code">源代码</string>
<string name="sponsor">赞助</string> <string name="sponsor">赞助</string>
<string name="working_directory">工作目录</string> <string name="working_directory">工作目录</string>
<string name="beta_settings">Beta 版设置</string>
<string name="disable_deprecated_warnings">禁用弃用警告</string> <string name="disable_deprecated_warnings">禁用弃用警告</string>
<string name="cache_size">缓存大小</string>
<string name="clear_cache">清除缓存</string>
<string name="notification_settings">通知</string> <string name="notification_settings">通知</string>
<string name="enable_notification">启用通知</string> <string name="enable_notification">启用通知</string>
<string name="dynamic_notification">在通知中显示实时网速</string> <string name="dynamic_notification">在通知中显示实时网速</string>
<string name="disable_notification_description">由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。</string> <string name="disable_notification_description">由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。</string>
<string name="disable_notification_description_legacy">由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。</string> <string name="disable_notification_description_legacy">由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。</string>
<string name="allow_bypass">允许绕过 VPN</string>
<string name="allow_bypass_description">启用后,应用可以绕过此 VPN 连接,直接使用底层网络。</string>
<string name="android_documentation">Android 文档</string>
<string name="auto_redirect">自动重定向</string> <string name="auto_redirect">自动重定向</string>
<string name="auto_redirect_description">需要 ROOT 权限</string> <string name="auto_redirect_description">需要 ROOT 权限</string>
<string name="system_http_proxy">系统 HTTP 代理</string> <string name="system_http_proxy">系统 HTTP 代理</string>
@@ -266,7 +274,7 @@
<string name="check_update_prompt_github">是否启用从 **GitHub** 自动检查更新?</string> <string name="check_update_prompt_github">是否启用从 **GitHub** 自动检查更新?</string>
<string name="update_track">更新轨道</string> <string name="update_track">更新轨道</string>
<string name="update_track_stable">稳定版</string> <string name="update_track_stable">稳定版</string>
<string name="update_track_beta">测试</string> <string name="update_track_beta">Beta </string>
<string name="update_track_not_supported">当前轨道尚不支持检查更新</string> <string name="update_track_not_supported">当前轨道尚不支持检查更新</string>
<string name="view_release">查看发布</string> <string name="view_release">查看发布</string>
<string name="downloading">下载中…</string> <string name="downloading">下载中…</string>
@@ -275,6 +283,22 @@
<string name="new_version_available">有新版本可用:%s</string> <string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自动更新</string> <string name="auto_update">自动更新</string>
<string name="auto_update_description">在后台自动下载和安装更新</string> <string name="auto_update_description">在后台自动下载和安装更新</string>
<string name="update_source">更新来源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 镜像</string>
<string name="fdroid_mirror_test_all">根据延迟自动选择</string>
<string name="fdroid_mirror_testing">测试中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失败</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">添加镜像</string>
<string name="fdroid_mirror_name_hint">名称</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自定义</string>
<string name="fdroid_mirror_invalid_url">无效的 URL</string>
<string name="fdroid_mirror_add_action">添加</string>
<string name="fdroid_mirror_delete">删除</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">静默安装</string> <string name="silent_install">静默安装</string>
@@ -399,6 +423,94 @@
<string name="content_description_collapse_search">折叠搜索</string> <string name="content_description_collapse_search">折叠搜索</string>
<string name="content_description_search_logs">搜索日志</string> <string name="content_description_search_logs">搜索日志</string>
<!-- Tools -->
<string name="title_tools">工具</string>
<string name="title_network">网络</string>
<string name="network_quality">网络质量</string>
<string name="network_quality_url">URL</string>
<string name="network_quality_serial">串行</string>
<string name="network_quality_http3">HTTP/3</string>
<string name="network_quality_max_runtime">最大运行时间</string>
<string name="network_quality_max_runtime_30s">30s</string>
<string name="network_quality_max_runtime_60s">60s</string>
<string name="network_quality_start">开始测试</string>
<string name="network_quality_cancel">取消测试</string>
<string name="network_quality_idle_latency">空闲延迟</string>
<string name="network_quality_download">下载</string>
<string name="network_quality_upload">上传</string>
<string name="network_quality_download_rpm">下载 RPM</string>
<string name="network_quality_upload_rpm">上传 RPM</string>
<string name="network_quality_confidence_high">置信度高</string>
<string name="network_quality_confidence_medium">置信度中</string>
<string name="network_quality_confidence_low">置信度低</string>
<string name="network_quality_metered_title">按流量计费连接</string>
<string name="network_quality_metered_message">您正在使用按流量计费的连接。此测试将消耗大量数据。</string>
<string name="network_quality_metered_continue">继续</string>
<string name="tool_configuration">配置</string>
<string name="tool_results">结果</string>
<string name="tool_outbound">出站</string>
<string name="tool_default_outbound">默认</string>
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">端点</string>
<string name="tailscale_status">状态</string>
<string name="tailscale_state">状态</string>
<string name="tailscale_network">网络</string>
<string name="tailscale_open_auth_url">打开认证链接</string>
<string name="tailscale_open_auth_url_qr_code">显示认证链接二维码</string>
<string name="tailscale_this_device">此设备</string>
<string name="tailscale_connected">已连接</string>
<string name="tailscale_not_connected">未连接</string>
<string name="tailscale_addresses">Tailscale 地址</string>
<string name="tailscale_details">详情</string>
<string name="tailscale_key_expiry">密钥过期</string>
<string name="tailscale_exit_node">出口节点</string>
<string name="tailscale_active">活跃</string>
<string name="stun_test">STUN 测试</string>
<string name="stun_server">服务器</string>
<string name="stun_start">开始测试</string>
<string name="stun_cancel">取消测试</string>
<string name="stun_external_address">外部地址</string>
<string name="stun_latency">延迟</string>
<string name="stun_nat_mapping">NAT 映射</string>
<string name="stun_nat_filtering">NAT 过滤</string>
<string name="stun_nat_type_detection">NAT 类型检测</string>
<string name="stun_nat_not_supported">服务器不支持</string>
<!-- Shared Report -->
<string name="report_empty"></string>
<string name="report_section_reports">报告</string>
<string name="report_section_files">文件</string>
<string name="report_delete_all">全部删除</string>
<string name="report_delete">删除</string>
<string name="report_share">分享</string>
<string name="report_share_with_config">附带配置分享</string>
<string name="report_metadata">元数据</string>
<string name="report_configuration">配置</string>
<string name="report_origin_local">本地</string>
<string name="service_not_started">服务未启动</string>
<string name="service_reload_required">需要重载服务以应用更改</string>
<string name="service_restart_required">需要重启服务以应用更改</string>
<!-- Crash Report -->
<string name="crash_report">崩溃报告</string>
<string name="crash_report_go_log">Go Crash Log</string>
<string name="crash_report_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">当遇到崩溃时,您将会收到报告。</string>
<!-- OOM Report -->
<string name="oom_report">内存不足报告</string>
<string name="oom_report_description">启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。</string>
<string name="oom_report_fetch">获取内存报告</string>
<string name="oom_report_enable_memory_limit">启用内存限制</string>
<string name="oom_report_enable_memory_limit_description">为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。</string>
<string name="oom_report_memory_limit">内存限制</string>
<string name="oom_report_kill_connections">终止连接</string>
<string name="oom_report_kill_connections_description">当服务内存超出限制时,终止所有连接以释放内存。</string>
<!-- Xposed Module --> <!-- Xposed Module -->
<string name="xposed_description">sing-box 的特权增强</string> <string name="xposed_description">sing-box 的特权增强</string>
<!-- Privileged Enhancement --> <!-- Privileged Enhancement -->

View File

@@ -23,6 +23,8 @@
<string name="action">操作</string> <string name="action">操作</string>
<string name="action_start">啟動</string> <string name="action_start">啟動</string>
<string name="action_deselect">取消選擇</string> <string name="action_deselect">取消選擇</string>
<string name="action_reload">重新載入</string>
<string name="action_restart">重新啟動</string>
<string name="expand">展開</string> <string name="expand">展開</string>
<string name="collapse">收合</string> <string name="collapse">收合</string>
<string name="expand_all">全部展開</string> <string name="expand_all">全部展開</string>
@@ -45,7 +47,7 @@
<string name="default_text">預設</string> <string name="default_text">預設</string>
<!-- Navigation Titles --> <!-- Navigation Titles -->
<string name="title_dashboard">儀表</string> <string name="title_dashboard">儀表</string>
<string name="title_configuration">設定檔</string> <string name="title_configuration">設定檔</string>
<string name="title_log">日誌</string> <string name="title_log">日誌</string>
<string name="title_settings">設定</string> <string name="title_settings">設定</string>
@@ -66,7 +68,7 @@
<string name="status_started">已啟動</string> <string name="status_started">已啟動</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_items">儀表板項目</string> <string name="dashboard_items">儀表</string>
<string name="memory">記憶體</string> <string name="memory">記憶體</string>
<string name="goroutines">協程</string> <string name="goroutines">協程</string>
<string name="upload">上傳</string> <string name="upload">上傳</string>
@@ -86,7 +88,7 @@
<string name="search_connections">搜尋連線…</string> <string name="search_connections">搜尋連線…</string>
<string name="close_connections_confirm">關閉所有連線?</string> <string name="close_connections_confirm">關閉所有連線?</string>
<string name="connection_state_all">全部</string> <string name="connection_state_all">全部</string>
<string name="connection_state_active"></string> <string name="connection_state_active"></string>
<string name="connection_state_closed">已關閉</string> <string name="connection_state_closed">已關閉</string>
<string name="connection_sort_date">日期</string> <string name="connection_sort_date">日期</string>
<string name="connection_sort_traffic">流量</string> <string name="connection_sort_traffic">流量</string>
@@ -198,12 +200,18 @@
<string name="source_code">原始碼</string> <string name="source_code">原始碼</string>
<string name="sponsor">贊助</string> <string name="sponsor">贊助</string>
<string name="working_directory">工作目錄</string> <string name="working_directory">工作目錄</string>
<string name="beta_settings">Beta 版設定</string>
<string name="disable_deprecated_warnings">停用過時警告</string> <string name="disable_deprecated_warnings">停用過時警告</string>
<string name="cache_size">快取大小</string>
<string name="clear_cache">清除快取</string>
<string name="notification_settings">通知</string> <string name="notification_settings">通知</string>
<string name="enable_notification">啟用通知</string> <string name="enable_notification">啟用通知</string>
<string name="dynamic_notification">在通知中顯示即時網速</string> <string name="dynamic_notification">在通知中顯示即時網速</string>
<string name="disable_notification_description">由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。</string> <string name="disable_notification_description">由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。</string>
<string name="disable_notification_description_legacy">由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。</string> <string name="disable_notification_description_legacy">由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。</string>
<string name="allow_bypass">允許繞過 VPN</string>
<string name="allow_bypass_description">啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。</string>
<string name="android_documentation">Android 文件</string>
<string name="auto_redirect">自動重定向</string> <string name="auto_redirect">自動重定向</string>
<string name="auto_redirect_description">需要 ROOT 權限</string> <string name="auto_redirect_description">需要 ROOT 權限</string>
<string name="system_http_proxy">系統 HTTP 代理</string> <string name="system_http_proxy">系統 HTTP 代理</string>
@@ -266,7 +274,7 @@
<string name="check_update_prompt_github">是否啟用從 **GitHub** 自動檢查更新?</string> <string name="check_update_prompt_github">是否啟用從 **GitHub** 自動檢查更新?</string>
<string name="update_track">更新通道</string> <string name="update_track">更新通道</string>
<string name="update_track_stable">穩定版</string> <string name="update_track_stable">穩定版</string>
<string name="update_track_beta">測試</string> <string name="update_track_beta">Beta </string>
<string name="update_track_not_supported">目前通道尚不支援檢查更新</string> <string name="update_track_not_supported">目前通道尚不支援檢查更新</string>
<string name="view_release">查看發布</string> <string name="view_release">查看發布</string>
<string name="downloading">下載中…</string> <string name="downloading">下載中…</string>
@@ -275,6 +283,22 @@
<string name="new_version_available">有新版本可用:%s</string> <string name="new_version_available">有新版本可用:%s</string>
<string name="auto_update">自動更新</string> <string name="auto_update">自動更新</string>
<string name="auto_update_description">在背景自動下載並安裝更新</string> <string name="auto_update_description">在背景自動下載並安裝更新</string>
<string name="update_source">更新來源</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="fdroid_mirror">F-Droid 鏡像</string>
<string name="fdroid_mirror_test_all">依延遲自動選擇</string>
<string name="fdroid_mirror_testing">測試中…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">失敗</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">新增鏡像</string>
<string name="fdroid_mirror_name_hint">名稱</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">自訂</string>
<string name="fdroid_mirror_invalid_url">無效的 URL</string>
<string name="fdroid_mirror_add_action">新增</string>
<string name="fdroid_mirror_delete">刪除</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">靜默安裝</string> <string name="silent_install">靜默安裝</string>
@@ -402,6 +426,94 @@
<string name="content_description_collapse_search">收合搜尋</string> <string name="content_description_collapse_search">收合搜尋</string>
<string name="content_description_search_logs">搜尋日誌</string> <string name="content_description_search_logs">搜尋日誌</string>
<!-- Tools -->
<string name="title_tools">工具</string>
<string name="title_network">網路</string>
<string name="network_quality">網路品質</string>
<string name="network_quality_url">URL</string>
<string name="network_quality_serial">序列</string>
<string name="network_quality_http3">HTTP/3</string>
<string name="network_quality_max_runtime">最大執行時間</string>
<string name="network_quality_max_runtime_30s">30s</string>
<string name="network_quality_max_runtime_60s">60s</string>
<string name="network_quality_start">開始測試</string>
<string name="network_quality_cancel">取消測試</string>
<string name="network_quality_idle_latency">閒置延遲</string>
<string name="network_quality_download">下載</string>
<string name="network_quality_upload">上傳</string>
<string name="network_quality_download_rpm">下載 RPM</string>
<string name="network_quality_upload_rpm">上傳 RPM</string>
<string name="network_quality_confidence_high">置信度高</string>
<string name="network_quality_confidence_medium">置信度中</string>
<string name="network_quality_confidence_low">置信度低</string>
<string name="network_quality_metered_title">按流量計費連線</string>
<string name="network_quality_metered_message">您正在使用按流量計費的連線。此測試將消耗大量數據。</string>
<string name="network_quality_metered_continue">繼續</string>
<string name="tool_configuration">配置</string>
<string name="tool_results">結果</string>
<string name="tool_outbound">出站</string>
<string name="tool_default_outbound">默認</string>
<!-- STUN Test -->
<!-- Tailscale -->
<string name="tailscale_endpoints">端點</string>
<string name="tailscale_status">狀態</string>
<string name="tailscale_state">狀態</string>
<string name="tailscale_network">網路</string>
<string name="tailscale_open_auth_url">開啟認證連結</string>
<string name="tailscale_open_auth_url_qr_code">顯示認證連結 QR 碼</string>
<string name="tailscale_this_device">此裝置</string>
<string name="tailscale_connected">已連線</string>
<string name="tailscale_not_connected">未連線</string>
<string name="tailscale_addresses">Tailscale 位址</string>
<string name="tailscale_details">詳情</string>
<string name="tailscale_key_expiry">金鑰到期</string>
<string name="tailscale_exit_node">出口節點</string>
<string name="tailscale_active">活躍</string>
<string name="stun_test">STUN 測試</string>
<string name="stun_server">伺服器</string>
<string name="stun_start">開始測試</string>
<string name="stun_cancel">取消測試</string>
<string name="stun_external_address">外部地址</string>
<string name="stun_latency">延遲</string>
<string name="stun_nat_mapping">NAT 映射</string>
<string name="stun_nat_filtering">NAT 過濾</string>
<string name="stun_nat_type_detection">NAT 類型偵測</string>
<string name="stun_nat_not_supported">伺服器不支援</string>
<!-- Shared Report -->
<string name="report_empty"></string>
<string name="report_section_reports">報告</string>
<string name="report_section_files">檔案</string>
<string name="report_delete_all">全部刪除</string>
<string name="report_delete">刪除</string>
<string name="report_share">分享</string>
<string name="report_share_with_config">附帶配置分享</string>
<string name="report_metadata">元數據</string>
<string name="report_configuration">配置</string>
<string name="report_origin_local">本地</string>
<string name="service_not_started">服務未啟動</string>
<string name="service_reload_required">需要重新載入服務以套用變更</string>
<string name="service_restart_required">需要重新啟動服務以套用變更</string>
<!-- Crash Report -->
<string name="crash_report">當機報告</string>
<string name="crash_report_go_log">Go Crash Log</string>
<string name="crash_report_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">當發生當機時,您將會收到報告。</string>
<!-- OOM Report -->
<string name="oom_report">記憶體不足報告</string>
<string name="oom_report_description">啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。</string>
<string name="oom_report_fetch">取得記憶體報告</string>
<string name="oom_report_enable_memory_limit">啟用記憶體限制</string>
<string name="oom_report_enable_memory_limit_description">為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。</string>
<string name="oom_report_memory_limit">記憶體限制</string>
<string name="oom_report_kill_connections">終止連線</string>
<string name="oom_report_kill_connections_description">當服務記憶體超出限制時,終止所有連線以釋放記憶體。</string>
<!-- Xposed Module --> <!-- Xposed Module -->
<string name="xposed_description">sing-box 的特權強化</string> <string name="xposed_description">sing-box 的特權強化</string>
<!-- Privileged Enhancement --> <!-- Privileged Enhancement -->

View File

@@ -23,6 +23,8 @@
<string name="action">Action</string> <string name="action">Action</string>
<string name="action_start">Start</string> <string name="action_start">Start</string>
<string name="action_deselect">Deselect</string> <string name="action_deselect">Deselect</string>
<string name="action_reload">Reload</string>
<string name="action_restart">Restart</string>
<string name="expand">Expand</string> <string name="expand">Expand</string>
<string name="collapse">Collapse</string> <string name="collapse">Collapse</string>
<string name="expand_all">Expand All</string> <string name="expand_all">Expand All</string>
@@ -198,12 +200,18 @@
<string name="source_code">Source Code</string> <string name="source_code">Source Code</string>
<string name="sponsor">Sponsor</string> <string name="sponsor">Sponsor</string>
<string name="working_directory">Working Directory</string> <string name="working_directory">Working Directory</string>
<string name="beta_settings">Beta Settings</string>
<string name="disable_deprecated_warnings">Disable Deprecated Warnings</string> <string name="disable_deprecated_warnings">Disable Deprecated Warnings</string>
<string name="cache_size">Cache Size</string>
<string name="clear_cache">Clear Cache</string>
<string name="notification_settings">Notification</string> <string name="notification_settings">Notification</string>
<string name="enable_notification">Enable Notification</string> <string name="enable_notification">Enable Notification</string>
<string name="dynamic_notification">Display realtime speed in notification</string> <string name="dynamic_notification">Display realtime speed in notification</string>
<string name="disable_notification_description">Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.</string> <string name="disable_notification_description">Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category.</string>
<string name="disable_notification_description_legacy">Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.</string> <string name="disable_notification_description_legacy">Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications.</string>
<string name="allow_bypass">Allow Bypass</string>
<string name="allow_bypass_description">If enabled, applications can bypass this VPN connection and instead use the underlying network directly.</string>
<string name="android_documentation">Android Documentation</string>
<string name="auto_redirect">Auto Redirect</string> <string name="auto_redirect">Auto Redirect</string>
<string name="auto_redirect_description">ROOT permission required</string> <string name="auto_redirect_description">ROOT permission required</string>
<string name="system_http_proxy">System HTTP Proxy</string> <string name="system_http_proxy">System HTTP Proxy</string>
@@ -264,6 +272,9 @@
<string name="check_update_automatic">Automatic Update Check</string> <string name="check_update_automatic">Automatic Update Check</string>
<string name="check_update_prompt_play">Would you like to enable automatic update checking from **Play Store**?</string> <string name="check_update_prompt_play">Would you like to enable automatic update checking from **Play Store**?</string>
<string name="check_update_prompt_github">Would you like to enable automatic update checking from **GitHub**?</string> <string name="check_update_prompt_github">Would you like to enable automatic update checking from **GitHub**?</string>
<string name="update_source">Update Source</string>
<string name="update_source_github">GitHub</string>
<string name="update_source_fdroid">F-Droid</string>
<string name="update_track">Update Track</string> <string name="update_track">Update Track</string>
<string name="update_track_stable">Stable</string> <string name="update_track_stable">Stable</string>
<string name="update_track_beta">Beta</string> <string name="update_track_beta">Beta</string>
@@ -275,6 +286,19 @@
<string name="new_version_available">New version available: %s</string> <string name="new_version_available">New version available: %s</string>
<string name="auto_update">Auto Update</string> <string name="auto_update">Auto Update</string>
<string name="auto_update_description">Automatically download and install updates in background</string> <string name="auto_update_description">Automatically download and install updates in background</string>
<string name="fdroid_mirror">F-Droid Mirror</string>
<string name="fdroid_mirror_test_all">Auto Select by Latency</string>
<string name="fdroid_mirror_testing">Testing…</string>
<string name="fdroid_mirror_latency">%d ms</string>
<string name="fdroid_mirror_failed">Failed</string>
<string name="fdroid_mirror_untested"></string>
<string name="fdroid_mirror_add">Add Mirror</string>
<string name="fdroid_mirror_name_hint">Name</string>
<string name="fdroid_mirror_url_hint">URL</string>
<string name="fdroid_mirror_custom">Custom</string>
<string name="fdroid_mirror_invalid_url">Invalid URL</string>
<string name="fdroid_mirror_add_action">Add</string>
<string name="fdroid_mirror_delete">Delete</string>
<!-- Silent Install --> <!-- Silent Install -->
<string name="silent_install">Silent Install</string> <string name="silent_install">Silent Install</string>
@@ -402,6 +426,105 @@
<string name="content_description_collapse_search">Collapse search</string> <string name="content_description_collapse_search">Collapse search</string>
<string name="content_description_search_logs">Search logs</string> <string name="content_description_search_logs">Search logs</string>
<!-- Tools -->
<string name="title_tools">Tools</string>
<string name="title_network">Network</string>
<string name="network_quality">Network Quality</string>
<string name="network_quality_url">URL</string>
<string name="network_quality_serial">Serial</string>
<string name="network_quality_http3">HTTP/3</string>
<string name="network_quality_max_runtime">Max Runtime</string>
<string name="network_quality_max_runtime_30s">30s</string>
<string name="network_quality_max_runtime_60s">60s</string>
<string name="network_quality_start">Start Test</string>
<string name="network_quality_cancel">Cancel Test</string>
<string name="network_quality_idle_latency">Idle Latency</string>
<string name="network_quality_download">Download</string>
<string name="network_quality_upload">Upload</string>
<string name="network_quality_download_rpm">Download RPM</string>
<string name="network_quality_upload_rpm">Upload RPM</string>
<string name="network_quality_confidence_high">Confidence High</string>
<string name="network_quality_confidence_medium">Confidence Medium</string>
<string name="network_quality_confidence_low">Confidence Low</string>
<string name="network_quality_metered_title">Metered Connection</string>
<string name="network_quality_metered_message">You\'re on a metered connection. This test will use a significant amount of data.</string>
<string name="network_quality_metered_continue">Continue</string>
<string name="tool_configuration">Configuration</string>
<string name="tool_results">Results</string>
<string name="tool_outbound">Outbound</string>
<string name="tool_default_outbound">Default</string>
<!-- Tailscale -->
<string name="tailscale" translatable="false">Tailscale</string>
<string name="tailscale_with_tag" translatable="false">Tailscale: %s</string>
<string name="tailscale_endpoints">Endpoints</string>
<string name="tailscale_status">Status</string>
<string name="tailscale_state">State</string>
<string name="tailscale_network">Network</string>
<string name="tailscale_magic_dns" translatable="false">MagicDNS</string>
<string name="tailscale_open_auth_url">Open Auth URL</string>
<string name="tailscale_open_auth_url_qr_code">Show Auth URL QR Code</string>
<string name="tailscale_this_device">This Device</string>
<string name="tailscale_connected">Connected</string>
<string name="tailscale_not_connected">Not Connected</string>
<string name="tailscale_addresses">Tailscale Addresses</string>
<string name="tailscale_details">Details</string>
<string name="tailscale_key_expiry">Key Expiry</string>
<string name="tailscale_os" translatable="false">OS</string>
<string name="tailscale_exit_node">Exit Node</string>
<string name="tailscale_active">Active</string>
<string name="tailscale_ipv4" translatable="false">IPv4</string>
<string name="tailscale_ipv6" translatable="false">IPv6</string>
<string name="tailscale_ping">Ping</string>
<string name="tailscale_ping_start">Start</string>
<string name="tailscale_ping_stop">Stop</string>
<string name="tailscale_ping_direct">Direct connection</string>
<string name="tailscale_ping_derp">DERP-relayed connection</string>
<!-- STUN Test -->
<string name="stun_test">STUN Test</string>
<string name="stun_server">Server</string>
<string name="stun_start">Start Test</string>
<string name="stun_cancel">Cancel Test</string>
<string name="stun_external_address">External Address</string>
<string name="stun_latency">Latency</string>
<string name="stun_nat_mapping">NAT Mapping</string>
<string name="stun_nat_filtering">NAT Filtering</string>
<string name="stun_nat_type_detection">NAT Type Detection</string>
<string name="stun_nat_not_supported">Not supported by server</string>
<!-- Shared Report -->
<string name="report_empty">Empty</string>
<string name="report_section_reports">Reports</string>
<string name="report_section_files">Files</string>
<string name="report_delete_all">Delete All</string>
<string name="report_delete">Delete</string>
<string name="report_share">Share</string>
<string name="report_share_with_config">Share With Configuration</string>
<string name="report_metadata">Metadata</string>
<string name="report_configuration">Configuration</string>
<string name="report_origin_local">Local</string>
<string name="service_not_started">Service not started</string>
<string name="service_reload_required">Reload service to apply changes</string>
<string name="service_restart_required">Restart service to apply changes</string>
<!-- Crash Report -->
<string name="crash_report">Crash Report</string>
<string name="crash_report_go_log">Go Crash Log</string>
<string name="crash_report_jvm_log">JVM Crash Log</string>
<string name="crash_report_description">You will receive a report when a crash occurs.</string>
<!-- OOM Report -->
<string name="oom_report">OOM Report</string>
<string name="oom_report_description">When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection.</string>
<string name="oom_report_fetch">Fetch Memory Report</string>
<string name="oom_report_enable_memory_limit">Enable Memory Limit</string>
<string name="oom_report_enable_memory_limit_description">Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit.</string>
<string name="oom_report_memory_limit">Memory Limit</string>
<string name="oom_report_kill_connections">Kill Connections</string>
<string name="oom_report_kill_connections_description">Kill all connections to free memory when the service memory exceeds the limit.</string>
<!-- Xposed Module --> <!-- Xposed Module -->
<string name="xposed_description">Privileged Enhancement for sing-box</string> <string name="xposed_description">Privileged Enhancement for sing-box</string>

View File

@@ -3,4 +3,7 @@
<cache-path <cache-path
name="cache" name="cache"
path="/" /> path="/" />
<external-files-path
name="external_files"
path="/" />
</paths> </paths>

View File

@@ -1 +1,2 @@
io.nekohasekai.sfa.xposed.XposedInit io.nekohasekai.sfa.xposed.XposedInit
io.nekohasekai.sfa.xposed.XposedInit101

View File

@@ -1,3 +1,3 @@
minApiVersion=100 minApiVersion=100
targetApiVersion=100 targetApiVersion=101
staticScope=true staticScope=true

View File

@@ -13,8 +13,10 @@ import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateSource
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.update.UpdateTrack
import io.nekohasekai.sfa.update.checkFDroidUpdate
object Vendor : VendorInterface { object Vendor : VendorInterface {
private const val TAG = "Vendor" private const val TAG = "Vendor"
@@ -93,19 +95,20 @@ object Vendor : VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)?, onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? = null ): ImageAnalysis.Analyzer? = null
override fun supportsTrackSelection(): Boolean = true override val hasCustomUpdate = true
override fun checkUpdateAsync(): UpdateInfo? { override val updateSources = listOf(UpdateSource.GITHUB, UpdateSource.FDROID)
val track = UpdateTrack.fromString(Settings.updateTrack)
return GitHubUpdateChecker().use { checker -> override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) {
checker.checkUpdate(track) 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() { override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
} }

View File

@@ -93,7 +93,7 @@ object Vendor : VendorInterface {
onCropArea: ((QRCodeCropArea?) -> Unit)?, onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? = null ): ImageAnalysis.Analyzer? = null
override fun supportsTrackSelection(): Boolean = true override val hasCustomUpdate = true
override fun checkUpdateAsync(): UpdateInfo? { override fun checkUpdateAsync(): UpdateInfo? {
val track = UpdateTrack.fromString(Settings.updateTrack) 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() { override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
} }

View File

@@ -92,7 +92,5 @@ object Vendor : VendorInterface {
} }
} }
override fun supportsTrackSelection(): Boolean = false
override fun checkUpdateAsync(): UpdateInfo? = null override fun checkUpdateAsync(): UpdateInfo? = null
} }

View File

@@ -1,5 +1,5 @@
[versions] [versions]
spotless = "8.1.0" spotless = "8.2.1"
ktlint = "1.7.1" ktlint = "1.7.1"
[plugins] [plugins]

View File

@@ -1,7 +1,7 @@
#Mon Jul 07 14:05:29 CST 2025 #Mon Jul 07 14:05:29 CST 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

@@ -21,9 +21,19 @@ import io.github.libxposed.api.utils.DexParser;
*/ */
public class XposedInterfaceWrapper implements XposedInterface { 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; mBase = base;
} }

View File

@@ -9,11 +9,16 @@ import androidx.annotation.NonNull;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface {
/** /**
* Instantiates a new Xposed module.<br/> * No-arg constructor for API 101 contract: the framework instantiates the module via
* When the module is loaded into the target process, the constructor will be called. * {@code Class.getDeclaredConstructor()}, then calls {@link #attachFramework}.
* */
* @param base The implementation interface provided by the framework, should not be used by the module public XposedModule() {
* @param param Information about the process in which the module is loaded 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) { public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) {
super(base); super(base);

View File

@@ -1,5 +1,6 @@
package io.github.libxposed.api; package io.github.libxposed.api;
import android.app.AppComponentFactory;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.os.Build; 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 { interface SystemServerLoadedParam {
/** /**
@@ -44,6 +45,26 @@ public interface XposedModuleInterface {
ClassLoader getClassLoader(); 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. * 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 * @param param Information about system server
*/ */
default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) { 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) {
}
} }

View File

@@ -2,4 +2,3 @@ VERSION_CODE=627
VERSION_NAME=1.13.0-rc.7 VERSION_NAME=1.13.0-rc.7
GO_VERSION=go1.25.7 GO_VERSION=go1.25.7