merge upstream
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
import io.nekohasekai.sfa.bg.ParceledListSlice;
|
||||||
|
|
||||||
|
interface INeighborTableCallback {
|
||||||
|
oneway void onNeighborTableUpdated(in ParceledListSlice entries);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package io.nekohasekai.sfa.bg;
|
||||||
|
|
||||||
|
parcelable NeighborEntry;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!!)
|
||||||
}
|
}
|
||||||
|
|||||||
251
app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt
Normal file
251
app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
49
app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java
Normal file
49
app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java
Normal 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];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
165
app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt
Normal file
165
app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.base
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberApplyServiceChangeNotifier(
|
||||||
|
serviceStatus: Status,
|
||||||
|
): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) {
|
||||||
|
{ mode ->
|
||||||
|
if (serviceStatus == Status.Started) {
|
||||||
|
GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,12 @@ sealed class UiEvent {
|
|||||||
|
|
||||||
object RequestReconnectService : UiEvent()
|
object RequestReconnectService : UiEvent()
|
||||||
|
|
||||||
object RestartToTakeEffect : UiEvent()
|
data class ApplyServiceChange(val mode: Mode) : UiEvent() {
|
||||||
|
enum class Mode {
|
||||||
|
Reload,
|
||||||
|
Restart,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.tools
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.DataObject
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.outlined.BugReport
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.bg.CrashReport
|
||||||
|
import io.nekohasekai.sfa.bg.CrashReportFile
|
||||||
|
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.DateFormat
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashReportDetailScreen(navController: NavController, reportId: String) {
|
||||||
|
val reports by CrashReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var files by remember { mutableStateOf<List<CrashReportFile>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var shareMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(report) {
|
||||||
|
if (report != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
files = CrashReportManager.availableFiles(report)
|
||||||
|
}
|
||||||
|
CrashReportManager.markAsRead(report)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val title = if (report != null) {
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
|
||||||
|
} else {
|
||||||
|
reportId
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasConfig = report != null && CrashReportManager.hasConfigFile(report)
|
||||||
|
|
||||||
|
fun shareReport(includeConfig: Boolean) {
|
||||||
|
val currentReport = report ?: return
|
||||||
|
scope.launch {
|
||||||
|
val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig)
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(intent, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!isLoading && files.isNotEmpty()) {
|
||||||
|
if (hasConfig) {
|
||||||
|
IconButton(onClick = { shareMenuExpanded = true }) {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = shareMenuExpanded,
|
||||||
|
onDismissRequest = { shareMenuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.report_share)) },
|
||||||
|
onClick = {
|
||||||
|
shareMenuExpanded = false
|
||||||
|
shareReport(includeConfig = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.report_share_with_config)) },
|
||||||
|
onClick = {
|
||||||
|
shareMenuExpanded = false
|
||||||
|
shareReport(includeConfig = true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { shareReport(includeConfig = false) }) {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (report != null) {
|
||||||
|
CrashReportManager.delete(report)
|
||||||
|
}
|
||||||
|
navController.navigateUp()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (files.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_section_files),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
files.forEachIndexed { index, file ->
|
||||||
|
val shape = when {
|
||||||
|
files.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
index == files.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
val icon = when (file.kind) {
|
||||||
|
CrashReportFile.Kind.METADATA -> Icons.Default.DataObject
|
||||||
|
CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal
|
||||||
|
CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport
|
||||||
|
CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
file.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.clickable {
|
||||||
|
if (file.kind == CrashReportFile.Kind.METADATA) {
|
||||||
|
navController.navigate("tools/crash_report/$reportId/metadata")
|
||||||
|
} else {
|
||||||
|
navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashReportMetadataScreen(navController: NavController, reportId: String) {
|
||||||
|
val reports by CrashReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(report) {
|
||||||
|
if (report != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
entries = loadMetadataEntries(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.report_metadata)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (entries.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
entries.forEachIndexed { index, (key, value) ->
|
||||||
|
val shape = when {
|
||||||
|
entries.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(
|
||||||
|
topStart = 12.dp,
|
||||||
|
topEnd = 12.dp,
|
||||||
|
)
|
||||||
|
index == entries.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
key,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(value)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clip(shape),
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMetadataEntries(report: CrashReport): List<Pair<String, String>> {
|
||||||
|
val metadataFile = CrashReportManager.availableFiles(report)
|
||||||
|
.find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList()
|
||||||
|
val content = metadataFile.file.readText()
|
||||||
|
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
|
||||||
|
return json.keys().asSequence()
|
||||||
|
.mapNotNull { key ->
|
||||||
|
val value = json.optString(key, "")
|
||||||
|
if (value.isNotBlank()) key to value else null
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
|
||||||
|
val reports by CrashReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var content by remember { mutableStateOf("") }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull()
|
||||||
|
val displayName = when (kind) {
|
||||||
|
CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log)
|
||||||
|
CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log)
|
||||||
|
CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration)
|
||||||
|
else -> fileKind
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(report, kind) {
|
||||||
|
if (report != null && kind != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = CrashReportManager.availableFiles(report).find { it.kind == kind }
|
||||||
|
content = if (file != null) CrashReportManager.loadFileContent(file) else ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(displayName) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (content.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.tools
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.outlined.DeleteSweep
|
||||||
|
import androidx.compose.material.icons.outlined.FlashOn
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.bg.CrashReportManager
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashReportListScreen(navController: NavController) {
|
||||||
|
val reports by CrashReportManager.reports.collectAsState()
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var crashTriggerExpanded by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
CrashReportManager.refresh()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.crash_report)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (reports.isNotEmpty() || BuildConfig.DEBUG) {
|
||||||
|
IconButton(onClick = { menuExpanded = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = {
|
||||||
|
menuExpanded = false
|
||||||
|
crashTriggerExpanded = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Crash Trigger") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.FlashOn,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { crashTriggerExpanded = !crashTriggerExpanded },
|
||||||
|
)
|
||||||
|
if (crashTriggerExpanded) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Go Crash",
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
crashTriggerExpanded = false
|
||||||
|
Libbox.triggerGoPanic()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Native Crash",
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
crashTriggerExpanded = false
|
||||||
|
Thread {
|
||||||
|
Thread.sleep(200)
|
||||||
|
throw RuntimeException("debug native crash")
|
||||||
|
}.start()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reports.isNotEmpty()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_delete_all),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.DeleteSweep,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
scope.launch {
|
||||||
|
CrashReportManager.deleteAll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_section_reports),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
if (reports.isEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
reports.forEachIndexed { index, report ->
|
||||||
|
val shape = when {
|
||||||
|
reports.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
index == reports.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
formatDate(report.date),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = if (!report.isRead) {
|
||||||
|
{
|
||||||
|
Badge(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.clickable {
|
||||||
|
navController.navigate("tools/crash_report/${report.id}")
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.crash_report_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
|
||||||
@@ -0,0 +1,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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.tools
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.DataObject
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.bg.OOMReport
|
||||||
|
import io.nekohasekai.sfa.bg.OOMReportFile
|
||||||
|
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.DateFormat
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun OOMReportDetailScreen(navController: NavController, reportId: String) {
|
||||||
|
val reports by OOMReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var files by remember { mutableStateOf<List<OOMReportFile>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var shareMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(report) {
|
||||||
|
if (report != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
files = OOMReportManager.availableFiles(report)
|
||||||
|
}
|
||||||
|
OOMReportManager.markAsRead(report)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val title = if (report != null) {
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date)
|
||||||
|
} else {
|
||||||
|
reportId
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasConfig = report != null && OOMReportManager.hasConfigFile(report)
|
||||||
|
|
||||||
|
fun shareReport(includeConfig: Boolean) {
|
||||||
|
val currentReport = report ?: return
|
||||||
|
scope.launch {
|
||||||
|
val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig)
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile)
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(intent, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!isLoading && files.isNotEmpty()) {
|
||||||
|
if (hasConfig) {
|
||||||
|
IconButton(onClick = { shareMenuExpanded = true }) {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = shareMenuExpanded,
|
||||||
|
onDismissRequest = { shareMenuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.report_share)) },
|
||||||
|
onClick = {
|
||||||
|
shareMenuExpanded = false
|
||||||
|
shareReport(includeConfig = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.report_share_with_config)) },
|
||||||
|
onClick = {
|
||||||
|
shareMenuExpanded = false
|
||||||
|
shareReport(includeConfig = true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { shareReport(includeConfig = false) }) {
|
||||||
|
Icon(Icons.Default.Share, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (report != null) {
|
||||||
|
OOMReportManager.delete(report)
|
||||||
|
}
|
||||||
|
navController.navigateUp()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (files.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_section_files),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
files.forEachIndexed { index, file ->
|
||||||
|
val shape = when {
|
||||||
|
files.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
index == files.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
val icon = when (file.kind) {
|
||||||
|
OOMReportFile.Kind.METADATA -> Icons.Default.DataObject
|
||||||
|
OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings
|
||||||
|
OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
file.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.then(
|
||||||
|
if (file.kind != OOMReportFile.Kind.PROFILE) {
|
||||||
|
Modifier.clickable {
|
||||||
|
if (file.kind == OOMReportFile.Kind.METADATA) {
|
||||||
|
navController.navigate("tools/oom_report/$reportId/metadata")
|
||||||
|
} else {
|
||||||
|
navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun OOMReportMetadataScreen(navController: NavController, reportId: String) {
|
||||||
|
val reports by OOMReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var entries by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
LaunchedEffect(report) {
|
||||||
|
if (report != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
entries = loadOOMMetadataEntries(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.report_metadata)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (entries.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
entries.forEachIndexed { index, (key, value) ->
|
||||||
|
val shape = when {
|
||||||
|
entries.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
index == entries.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(key, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(value) },
|
||||||
|
modifier = Modifier.clip(shape),
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadOOMMetadataEntries(report: OOMReport): List<Pair<String, String>> {
|
||||||
|
val metadataFile = OOMReportManager.availableFiles(report)
|
||||||
|
.find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList()
|
||||||
|
val content = metadataFile.file.readText()
|
||||||
|
val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList()
|
||||||
|
return json.keys().asSequence()
|
||||||
|
.mapNotNull { key ->
|
||||||
|
val value = json.optString(key, "")
|
||||||
|
if (value.isNotBlank()) key to value else null
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) {
|
||||||
|
val reports by OOMReportManager.reports.collectAsState()
|
||||||
|
val report = reports.find { it.id == reportId }
|
||||||
|
var content by remember { mutableStateOf("") }
|
||||||
|
var displayName by remember { mutableStateOf(fileKind) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull()
|
||||||
|
|
||||||
|
LaunchedEffect(report, kind) {
|
||||||
|
if (report != null && kind != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = OOMReportManager.availableFiles(report).find { it.kind == kind }
|
||||||
|
if (file != null) {
|
||||||
|
displayName = file.displayName
|
||||||
|
content = OOMReportManager.loadFileContent(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(displayName) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (content.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.screen.tools
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.outlined.DeleteSweep
|
||||||
|
import androidx.compose.material.icons.outlined.Memory
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.sfa.Application
|
||||||
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.bg.OOMReportManager
|
||||||
|
import io.nekohasekai.sfa.compose.base.UiEvent
|
||||||
|
import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier
|
||||||
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun OOMReportListScreen(
|
||||||
|
navController: NavController,
|
||||||
|
serviceStatus: Status = Status.Stopped,
|
||||||
|
) {
|
||||||
|
val reports by OOMReportManager.reports.collectAsState()
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus)
|
||||||
|
|
||||||
|
var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) }
|
||||||
|
var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) }
|
||||||
|
var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) }
|
||||||
|
var showMemoryLimitDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
OOMReportManager.refresh()
|
||||||
|
val storedLimit = Settings.oomMemoryLimitMB
|
||||||
|
if (!memoryLimitOptions.contains(storedLimit)) {
|
||||||
|
oomMemoryLimitMB = memoryLimitOptions.first()
|
||||||
|
Settings.oomMemoryLimitMB = oomMemoryLimitMB
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideTopBar {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.oom_report)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.navigateUp() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { menuExpanded = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.oom_report_fetch)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Outlined.Memory, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
if (serviceStatus != Status.Started) {
|
||||||
|
errorMessage =
|
||||||
|
Application.application.getString(R.string.service_not_started)
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
val failure =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
Libbox.newStandaloneCommandClient().triggerOOMReport()
|
||||||
|
}.exceptionOrNull()
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
errorMessage = failure.message ?: failure.toString()
|
||||||
|
} else {
|
||||||
|
delay(1000)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
OOMReportManager.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (reports.isNotEmpty()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_delete_all),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.DeleteSweep,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
scope.launch { OOMReportManager.deleteAll() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// Reports section
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_section_reports),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
if (reports.isEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.report_empty),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
reports.forEachIndexed { index, report ->
|
||||||
|
val shape = when {
|
||||||
|
reports.size == 1 -> RoundedCornerShape(12.dp)
|
||||||
|
index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||||
|
index == reports.lastIndex -> RoundedCornerShape(
|
||||||
|
bottomStart = 12.dp,
|
||||||
|
bottomEnd = 12.dp,
|
||||||
|
)
|
||||||
|
else -> RoundedCornerShape(0.dp)
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
formatDate(report.date),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = if (!report.isRead) {
|
||||||
|
{
|
||||||
|
Badge(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.clickable {
|
||||||
|
navController.navigate("tools/oom_report/${report.id}")
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.oom_report_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings section
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_settings),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.oom_report_enable_memory_limit),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(stringResource(R.string.oom_report_enable_memory_limit_description))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = oomKillerEnabled,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
oomKillerEnabled = checked
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
Settings.oomKillerEnabled = checked
|
||||||
|
Application.application.reloadSetupOptions()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
AnimatedVisibility(visible = oomKillerEnabled) {
|
||||||
|
Column {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.oom_report_memory_limit),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L))
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { showMemoryLimitDialog = true },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.oom_report_kill_connections),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(stringResource(R.string.oom_report_kill_connections_description))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = oomKillerKillConnections,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
oomKillerKillConnections = checked
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
Settings.oomKillerDisabled = !checked
|
||||||
|
Application.application.reloadSetupOptions()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage?.let { message ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { errorMessage = null },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { errorMessage = null }) {
|
||||||
|
Text(stringResource(android.R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = { Text(message) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMemoryLimitDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showMemoryLimitDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.oom_report_memory_limit)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
memoryLimitOptions.forEach { value ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = value == oomMemoryLimitMB,
|
||||||
|
onClick = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable {
|
||||||
|
oomMemoryLimitMB = value
|
||||||
|
showMemoryLimitDialog = false
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
Settings.oomMemoryLimitMB = value
|
||||||
|
Application.application.reloadSetupOptions()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
14
app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt
Normal file
14
app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt
Normal file
49
app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt
Normal file
11
app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,7 @@
|
|||||||
<cache-path
|
<cache-path
|
||||||
name="cache"
|
name="cache"
|
||||||
path="/" />
|
path="/" />
|
||||||
|
<external-files-path
|
||||||
|
name="external_files"
|
||||||
|
path="/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
io.nekohasekai.sfa.xposed.XposedInit
|
io.nekohasekai.sfa.xposed.XposedInit
|
||||||
|
io.nekohasekai.sfa.xposed.XposedInit101
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
minApiVersion=100
|
minApiVersion=100
|
||||||
targetApiVersion=100
|
targetApiVersion=101
|
||||||
staticScope=true
|
staticScope=true
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,5 @@ object Vendor : VendorInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun supportsTrackSelection(): Boolean = false
|
|
||||||
|
|
||||||
override fun checkUpdateAsync(): UpdateInfo? = null
|
override fun checkUpdateAsync(): UpdateInfo? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
spotless = "8.1.0"
|
spotless = "8.2.1"
|
||||||
ktlint = "1.7.1"
|
ktlint = "1.7.1"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user