From 7d1e7c72cebdce23ea4f5f3dcfc8d12a4dc29633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Feb 2026 19:20:50 +0800 Subject: [PATCH 01/45] Bump version --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index cd9c953..c773e52 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=623 -VERSION_NAME=1.13.0-rc.6 +VERSION_CODE=628 +VERSION_NAME=1.13.0 GO_VERSION=go1.25.7 From 99791bdffb3953b3dcd4ef7974236222b93ee025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 2 Mar 2026 14:50:07 +0800 Subject: [PATCH 02/45] Update dependencies --- .../java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt | 4 +--- .../sfa/compose/screen/dashboard/ProfilePickerSheet.kt | 2 +- .../sfa/compose/screen/dashboard/ProfileSelectorButton.kt | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt index d241a2c..15f8ba7 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -86,9 +86,7 @@ class GitHubUpdateChecker : Closeable { } } - private fun isNewerThanCurrent(versionName: String): Boolean { - return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) - } + private fun isNewerThanCurrent(versionName: String): Boolean = Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { if (Libbox.compareSemver(version.versionName, other.versionName)) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt index c4a8631..c1f50c8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.ui.graphics.lerp import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -52,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt index 31a5daa..240a5c5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt @@ -19,8 +19,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d88dc34..d54d9f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -spotless = "8.1.0" +spotless = "8.2.1" ktlint = "1.7.1" [plugins] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 477070d..2a565f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Mon Jul 07 14:05:29 CST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 172199dfc39be91ba95394b0dab20735a88ef33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 5 Mar 2026 21:44:20 +0800 Subject: [PATCH 03/45] Bump version --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index c773e52..66d15df 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=628 -VERSION_NAME=1.13.0 +VERSION_CODE=631 +VERSION_NAME=1.13.1 GO_VERSION=go1.25.7 From 7777469b5d21bc0312ed38bede457ee3128260e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 7 Mar 2026 15:54:18 +0800 Subject: [PATCH 04/45] Bump version 1.13.2 --- version.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/version.properties b/version.properties index 66d15df..5eee217 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=631 -VERSION_NAME=1.13.1 -GO_VERSION=go1.25.7 +VERSION_CODE=632 +VERSION_NAME=1.13.2 +GO_VERSION=go1.25.8 From 0d1ee7aa80f52a3a6947a85101ac94cd3d0ad77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 8 Mar 2026 21:27:57 +0800 Subject: [PATCH 05/45] Hide service settings when battery optimization is already ignored --- .../compose/screen/settings/SettingsScreen.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index f468f51..efe94dd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -153,31 +153,31 @@ fun SettingsScreen(navController: NavController) { ), ) - ListItem( - headlineContent = { - Text( - stringResource(R.string.service), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (!isBatteryOptimizationIgnored) { + if (!isBatteryOptimizationIgnored) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.service), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { Badge(containerColor = MaterialTheme.colorScheme.primary) - } - }, - modifier = Modifier.clickable { navController.navigate("settings/service") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) + }, + modifier = Modifier.clickable { navController.navigate("settings/service") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } ListItem( headlineContent = { From 868c1de2ff44d0925ec155b8cc30e96edac113cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 11 Mar 2026 16:45:27 +0800 Subject: [PATCH 06/45] Add F-Droid as update check sources --- .../io/nekohasekai/sfa/vendor/UpdateWorker.kt | 11 +- .../sfa/compose/navigation/SFANavigation.kt | 11 + .../screen/settings/AppSettingsScreen.kt | 295 ++++++++++- .../screen/settings/FDroidMirrorScreen.kt | 456 ++++++++++++++++++ .../nekohasekai/sfa/constant/SettingsKey.kt | 3 + .../io/nekohasekai/sfa/database/Settings.kt | 3 + .../sfa/update/FDroidUpdateChecker.kt | 27 ++ .../io/nekohasekai/sfa/update/UpdateSource.kt | 14 + .../nekohasekai/sfa/vendor/VendorInterface.kt | 43 +- app/src/main/res/values-zh-rCN/strings.xml | 18 + app/src/main/res/values-zh-rTW/strings.xml | 18 + app/src/main/res/values/strings.xml | 18 + app/src/main/res/xml/cache_paths.xml | 4 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 21 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 6 +- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 2 - 16 files changed, 874 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt index 7b14573..a4ee0af 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -11,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate import java.util.concurrent.TimeUnit class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { @@ -59,8 +61,13 @@ class UpdateWorker(private val appContext: Context, params: WorkerParameters) : Log.d(TAG, "Checking for updates...") return try { - val track = UpdateTrack.fromString(Settings.updateTrack) - val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } + val updateInfo = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(appContext) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { it.checkUpdate(track) } + } + } if (updateInfo == null) { Log.d(TAG, "No update available") diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 2f46d17..d6e5f22 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -28,6 +28,7 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen @@ -224,6 +225,16 @@ fun SFANavHost( AppSettingsScreen(navController = navController) } + composable( + route = "settings/fdroid_mirror", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + FDroidMirrorScreen(navController = navController) + } + composable( route = "settings/core", enterTransition = slideInFromRight, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 8aa6d21..99c82d2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.text.format.Formatter import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.background @@ -27,6 +28,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language @@ -62,6 +65,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -71,6 +75,7 @@ import androidx.core.os.LocaleListCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R @@ -78,6 +83,7 @@ import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack import io.nekohasekai.sfa.utils.HookStatusClient @@ -88,6 +94,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.xmlpull.v1.XmlPullParser +import java.io.File import java.util.Locale import android.provider.Settings as AndroidSettings @@ -113,10 +120,12 @@ fun AppSettingsScreen(navController: NavController) { val hasUpdate by UpdateState.hasUpdate val updateInfo by UpdateState.updateInfo val isChecking by UpdateState.isChecking + var showSourceDialog by remember { mutableStateOf(false) } + var currentSource by remember { mutableStateOf(Settings.updateSource) } var showTrackDialog by remember { mutableStateOf(false) } var currentTrack by remember { mutableStateOf(Settings.updateTrack) } var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } - var showErrorDialog by remember { mutableStateOf(null) } + var showErrorDialog by remember { mutableStateOf(null) } var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } @@ -144,8 +153,22 @@ fun AppSettingsScreen(navController: NavController) { mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) } + var cacheSize by remember { mutableStateOf(0L) } + var cacheSizeText by remember { mutableStateOf("") } + + fun refreshCacheSize() { + scope.launch(Dispatchers.IO) { + val size = calculateDirSize(context.cacheDir) + withContext(Dispatchers.Main) { + cacheSize = size + cacheSizeText = Formatter.formatFileSize(context, size) + } + } + } + LaunchedEffect(Unit) { HookStatusClient.refresh() + refreshCacheSize() } // Re-check states when returning from background (e.g., after granting permission) @@ -183,6 +206,21 @@ fun AppSettingsScreen(navController: NavController) { } } + if (showSourceDialog) { + UpdateSourceDialog( + currentSource = currentSource, + onSourceSelected = { source -> + currentSource = source + UpdateState.clear() + scope.launch(Dispatchers.IO) { + Settings.updateSource = source + } + showSourceDialog = false + }, + onDismiss = { showSourceDialog = false }, + ) + } + if (showTrackDialog) { UpdateTrackDialog( currentTrack = currentTrack, @@ -198,11 +236,11 @@ fun AppSettingsScreen(navController: NavController) { ) } - showErrorDialog?.let { messageRes -> + showErrorDialog?.let { message -> AlertDialog( onDismissRequest = { showErrorDialog = null }, title = { Text(stringResource(R.string.check_update)) }, - text = { Text(stringResource(messageRes)) }, + text = { Text(message) }, confirmButton = { TextButton(onClick = { showErrorDialog = null }) { Text(stringResource(R.string.ok)) @@ -440,13 +478,80 @@ fun AppSettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { showLanguageDialog = true }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, ), ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.cache_size), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + if (cacheSizeText.isNotEmpty()) { + Text(cacheSizeText, style = MaterialTheme.typography.bodyMedium) + } + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip( + if (cacheSize > 0L) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (cacheSize > 0L) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.clear_cache), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + scope.launch(Dispatchers.IO) { + context.cacheDir?.listFiles()?.forEach { it.deleteRecursively() } + withContext(Dispatchers.Main) { + cacheSize = 0L + cacheSizeText = Formatter.formatFileSize(context, 0L) + } + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } } } @@ -555,14 +660,21 @@ fun AppSettingsScreen(navController: NavController) { ), ) { Column { + val isFDroid = UpdateSource.fromString(currentSource) == UpdateSource.FDROID val updateItemCount = run { var count = 0 - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + count += 1 + } + if (Vendor.hasCustomUpdate) { + count += 1 + } + if (isFDroid) { count += 1 } count += 1 - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { count += 1 if (silentInstallEnabled) { count += 1 @@ -574,7 +686,7 @@ fun AppSettingsScreen(navController: NavController) { } } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { count += 1 } count @@ -592,7 +704,39 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsTrackSelection()) { + if (Vendor.updateSources.size > 1) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update_source), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val sourceName = when (UpdateSource.fromString(currentSource)) { + UpdateSource.GITHUB -> stringResource(R.string.update_source_github) + UpdateSource.FDROID -> stringResource(R.string.update_source_fdroid) + } + Text(sourceName, style = MaterialTheme.typography.bodyMedium) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { showSourceDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -601,9 +745,13 @@ fun AppSettingsScreen(navController: NavController) { ) }, supportingContent = { - val trackName = when (UpdateTrack.fromString(currentTrack)) { - UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) - UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + val trackName = if (isFDroid) { + stringResource(R.string.update_track_stable) + } else { + when (UpdateTrack.fromString(currentTrack)) { + UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) + UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + } } Text(trackName, style = MaterialTheme.typography.bodyMedium) }, @@ -615,8 +763,63 @@ fun AppSettingsScreen(navController: NavController) { ) }, modifier = + updateItemModifier().let { + if (isFDroid) it.alpha(0.38f) else it.clickable { showTrackDialog = true } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (isFDroid) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val mirrorUrl = Settings.fdroidMirrorUrl + val mirrorName = remember(mirrorUrl) { + val iter = Libbox.getFDroidMirrors() + var name: String? = null + while (iter.hasNext()) { + val m = iter.next() + if (m.url == mirrorUrl) { + name = m.name + break + } + } + if (name == null) { + val customMirrors = Settings.fdroidCustomMirrors + for (entry in customMirrors) { + val parts = entry.split("|", limit = 2) + if (parts.size == 2 && parts[1] == mirrorUrl) { + name = parts[0] + break + } + } + } + name ?: mirrorUrl + } + Text( + mirrorName, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = updateItemModifier() - .clickable { showTrackDialog = true }, + .clickable { navController.navigate("settings/fdroid_mirror") }, colors = ListItemDefaults.colors( containerColor = Color.Transparent, @@ -656,7 +859,7 @@ fun AppSettingsScreen(navController: NavController) { ), ) - if (Vendor.supportsSilentInstall()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -836,7 +1039,7 @@ fun AppSettingsScreen(navController: NavController) { } } - if (Vendor.supportsAutoUpdate()) { + if (Vendor.hasCustomUpdate) { ListItem( headlineContent = { Text( @@ -940,15 +1143,17 @@ fun AppSettingsScreen(navController: NavController) { val result = Vendor.checkUpdateAsync() UpdateState.setUpdate(result) if (result == null) { - showErrorDialog = R.string.no_updates_available + showErrorDialog = context.getString(R.string.no_updates_available) } else { showUpdateAvailableDialog = true } } catch (_: UpdateCheckException.TrackNotSupported) { UpdateState.setUpdate(null) - showErrorDialog = R.string.update_track_not_supported - } catch (_: Exception) { + showErrorDialog = context.getString(R.string.update_track_not_supported) + } catch (e: Exception) { + Log.e("AppSettingsScreen", "checkUpdateAsync failed", e) UpdateState.setUpdate(null) + showErrorDialog = e.message } } UpdateState.isChecking.value = false @@ -998,6 +1203,53 @@ fun AppSettingsScreen(navController: NavController) { } } +@Composable +private fun UpdateSourceDialog( + currentSource: String, + onSourceSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val sources = listOf( + "github" to stringResource(R.string.update_source_github), + "fdroid" to stringResource(R.string.update_source_fdroid), + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.update_source)) }, + text = { + Column { + sources.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSourceSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentSource == value, + onClick = { onSourceSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + @Composable private fun UpdateTrackDialog( currentTrack: String, @@ -1108,6 +1360,15 @@ private fun LanguageDialog( ) } +private fun calculateDirSize(dir: File?): Long { + if (dir == null || !dir.exists()) return 0 + var size = 0L + dir.listFiles()?.forEach { file -> + size += if (file.isDirectory) calculateDirSize(file) else file.length() + } + return size +} + private fun getSupportedLocales(context: Context): List { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val localeConfig = LocaleConfig(context) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt new file mode 100644 index 0000000..1bce659 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/FDroidMirrorScreen.kt @@ -0,0 +1,456 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.webkit.URLUtil +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private data class MirrorEntry( + val url: String, + val name: String, + val country: String, + val isCustom: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FDroidMirrorScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.fdroid_mirror)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val scope = rememberCoroutineScope() + var selectedMirrorUrl by remember { mutableStateOf(Settings.fdroidMirrorUrl) } + var isTesting by remember { mutableStateOf(false) } + val latencyResults = remember { mutableStateMapOf() } + val latencyErrors = remember { mutableStateMapOf() } + var showAddForm by remember { mutableStateOf(false) } + var newMirrorName by remember { mutableStateOf("") } + var newMirrorUrl by remember { mutableStateOf("") } + var urlError by remember { mutableStateOf(null) } + val invalidUrlMessage = stringResource(R.string.fdroid_mirror_invalid_url) + var customMirrors by remember { mutableStateOf(Settings.fdroidCustomMirrors) } + + val builtinMirrors = remember { + val mirrors = mutableListOf() + val iter = Libbox.getFDroidMirrors() + while (iter.hasNext()) { + val m = iter.next() + mirrors.add(MirrorEntry(url = m.url, name = m.name, country = m.country)) + } + mirrors + } + + val parsedCustomMirrors = remember(customMirrors) { + customMirrors.map { entry -> + val parts = entry.split("|", limit = 2) + if (parts.size == 2) { + MirrorEntry(url = parts[1], name = parts[0], country = "", isCustom = true) + } else { + MirrorEntry(url = entry, name = entry, country = "", isCustom = true) + } + } + } + + val allMirrors = builtinMirrors + parsedCustomMirrors + + fun selectMirror(url: String) { + selectedMirrorUrl = url + Settings.fdroidMirrorUrl = url + } + + fun testAllMirrors() { + isTesting = true + latencyResults.clear() + latencyErrors.clear() + scope.launch { + allMirrors.map { mirror -> + async(Dispatchers.IO) { + val r = Libbox.pingFDroidMirror(mirror.url) + withContext(Dispatchers.Main) { + if (r.latencyMs < 0) { + latencyErrors[r.url] = true + } else { + latencyResults[r.url] = r.latencyMs + } + } + } + }.awaitAll() + val fastest = latencyResults.minByOrNull { it.value } + if (fastest != null) { + selectMirror(fastest.key) + } + isTesting = false + } + } + + val grouped = remember(builtinMirrors) { + builtinMirrors.groupBy { it.country } + } + val countryOrder = remember(grouped) { grouped.keys.toList() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + FilledTonalButton( + onClick = { testAllMirrors() }, + enabled = !isTesting, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_testing)) + } else { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.fdroid_mirror_test_all)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + countryOrder.forEach { country -> + val mirrors = grouped[country] ?: return@forEach + + Text( + text = country, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + mirrors.forEachIndexed { index, mirror -> + val shape = when { + mirrors.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == mirrors.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + Text( + text = stringResource(R.string.fdroid_mirror_custom), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + parsedCustomMirrors.forEachIndexed { index, mirror -> + val isLast = index == parsedCustomMirrors.lastIndex && !showAddForm + val shape = when { + index == 0 && isLast -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + isLast -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + mirror.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + mirror.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + RadioButton( + selected = selectedMirrorUrl == mirror.url, + onClick = { selectMirror(mirror.url) }, + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + LatencyBadge( + url = mirror.url, + latencyResults = latencyResults, + latencyErrors = latencyErrors, + ) + IconButton(onClick = { + val encoded = "${mirror.name}|${mirror.url}" + val newSet = customMirrors.toMutableSet() + newSet.remove(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + if (selectedMirrorUrl == mirror.url) { + selectMirror("https://f-droid.org/repo") + } + }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.fdroid_mirror_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + modifier = Modifier + .clip(shape) + .clickable { selectMirror(mirror.url) }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newMirrorName, + onValueChange = { newMirrorName = it }, + label = { Text(stringResource(R.string.fdroid_mirror_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = newMirrorUrl, + onValueChange = { + newMirrorUrl = it + urlError = null + }, + label = { Text(stringResource(R.string.fdroid_mirror_url_hint)) }, + singleLine = true, + isError = urlError != null, + supportingText = urlError?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { + val url = newMirrorUrl.trim().trimEnd('/') + if (!URLUtil.isHttpsUrl(url)) { + urlError = invalidUrlMessage + return@Button + } + val name = newMirrorName.trim().ifEmpty { url } + val encoded = "$name|$url" + val newSet = customMirrors.toMutableSet() + newSet.add(encoded) + customMirrors = newSet + Settings.fdroidCustomMirrors = newSet + newMirrorName = "" + newMirrorUrl = "" + urlError = null + showAddForm = false + }) { + Text(stringResource(R.string.fdroid_mirror_add_action)) + } + } + } + } else { + ListItem( + headlineContent = { + Text( + stringResource(R.string.fdroid_mirror_add), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip( + if (parsedCustomMirrors.isEmpty()) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + }, + ) + .clickable { showAddForm = true }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun LatencyBadge( + url: String, + latencyResults: Map, + latencyErrors: Map, +) { + val latency = latencyResults[url] + val failed = latencyErrors[url] == true + when { + latency != null -> { + Text( + text = stringResource(R.string.fdroid_mirror_latency, latency), + style = MaterialTheme.typography.labelMedium, + color = when { + latency < 100 -> MaterialTheme.colorScheme.primary + latency < 500 -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.error + }, + ) + } + failed -> { + Text( + text = stringResource(R.string.fdroid_mirror_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + else -> {} + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 3109681..f34199c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -5,7 +5,10 @@ object SettingsKey { const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" const val UPDATE_CHECK_PROMPTED = "update_check_prompted" + const val UPDATE_SOURCE = "update_source" const val UPDATE_TRACK = "update_track" + const val FDROID_MIRROR_URL = "fdroid_mirror_url" + const val FDROID_CUSTOM_MIRRORS = "fdroid_custom_mirrors" const val SILENT_INSTALL_ENABLED = "silent_install_enabled" const val SILENT_INSTALL_METHOD = "silent_install_method" const val AUTO_UPDATE_ENABLED = "auto_update_enabled" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 23b1d8b..ed37cb6 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -41,6 +41,7 @@ object Settings { var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) + var updateSource by dataStore.string(SettingsKey.UPDATE_SOURCE) { "github" } var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { @@ -62,6 +63,8 @@ object Settings { "SHIZUKU" } } + var fdroidMirrorUrl by dataStore.string(SettingsKey.FDROID_MIRROR_URL) { "https://f-droid.org/repo" } + var fdroidCustomMirrors by dataStore.stringSet(SettingsKey.FDROID_CUSTOM_MIRRORS) { emptySet() } var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } diff --git a/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt new file mode 100644 index 0000000..98af76f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/FDroidUpdateChecker.kt @@ -0,0 +1,27 @@ +package io.nekohasekai.sfa.update + +import android.content.Context +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.database.Settings + +fun checkFDroidUpdate(context: Context): UpdateInfo? { + val packageName = context.packageName + + @Suppress("DEPRECATION") + val versionCode = context.packageManager.getPackageInfo(packageName, 0).versionCode + val result = Libbox.checkFDroidUpdate( + Settings.fdroidMirrorUrl, + packageName, + versionCode, + context.cacheDir.absolutePath, + ) ?: return null + return UpdateInfo( + versionCode = result.versionCode, + versionName = result.versionName, + downloadUrl = result.downloadURL, + releaseUrl = "https://f-droid.org/packages/$packageName/", + releaseNotes = null, + isPrerelease = false, + fileSize = result.fileSize, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt new file mode 100644 index 0000000..006ad5f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateSource.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.update + +enum class UpdateSource { + GITHUB, + FDROID, + ; + + companion object { + fun fromString(value: String): UpdateSource = when (value.lowercase()) { + "fdroid" -> FDROID + else -> GITHUB + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index e72e00c..db8fd2a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -4,6 +4,7 @@ import android.app.Activity import androidx.camera.core.ImageAnalysis import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource interface VendorInterface { fun checkUpdate(activity: Activity, byUser: Boolean) @@ -14,53 +15,17 @@ interface VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)? = null, ): ImageAnalysis.Analyzer? - /** - * Check if Per-app Proxy feature is available - * @return true if available, false if disabled (e.g., for Play Store builds) - */ fun isPerAppProxyAvailable(): Boolean = true - /** - * Check if track selection is available (e.g., stable/beta) - * @return true if track selection is supported - */ - fun supportsTrackSelection(): Boolean = false + val hasCustomUpdate: Boolean get() = false + + val updateSources: List get() = listOf(UpdateSource.GITHUB) - /** - * Check for updates asynchronously - * @return UpdateInfo if update is available, null otherwise - */ fun checkUpdateAsync(): UpdateInfo? = null - /** - * Check if silent install feature is available - * @return true if silent install is supported (Other flavor only) - */ - fun supportsSilentInstall(): Boolean = false - - /** - * Check if auto update feature is available - * @return true if auto update is supported (Other flavor only) - */ - fun supportsAutoUpdate(): Boolean = false - - /** - * Schedule auto update worker - */ fun scheduleAutoUpdate() {} - /** - * Verify if the specified silent install method is available - * @param method The install method (SHIZUKU or ROOT) - * @return true if the method is available and working - */ suspend fun verifySilentInstallMethod(method: String): Boolean = false - /** - * Download and install an APK update - * @param context The context - * @param downloadUrl The URL to download the APK from - * @throws Exception if download or install fails - */ suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8d06b38..451032f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -199,6 +199,8 @@ 赞助 工作目录 禁用弃用警告 + 缓存大小 + 清除缓存 通知 启用通知 在通知中显示实时网速 @@ -275,6 +277,22 @@ 有新版本可用:%s 自动更新 在后台自动下载和安装更新 + 更新来源 + GitHub + F-Droid + F-Droid 镜像 + 根据延迟自动选择 + 测试中… + %d ms + 失败 + + 添加镜像 + 名称 + URL + 自定义 + 无效的 URL + 添加 + 删除 静默安装 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b9a0b99..70e5295 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -199,6 +199,8 @@ 贊助 工作目錄 停用過時警告 + 快取大小 + 清除快取 通知 啟用通知 在通知中顯示即時網速 @@ -275,6 +277,22 @@ 有新版本可用:%s 自動更新 在背景自動下載並安裝更新 + 更新來源 + GitHub + F-Droid + F-Droid 鏡像 + 依延遲自動選擇 + 測試中… + %d ms + 失敗 + + 新增鏡像 + 名稱 + URL + 自訂 + 無效的 URL + 新增 + 刪除 靜默安裝 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9980c2..63a3787 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,8 @@ Sponsor Working Directory Disable Deprecated Warnings + Cache Size + Clear Cache Notification Enable Notification Display realtime speed in notification @@ -264,6 +266,9 @@ Automatic Update Check Would you like to enable automatic update checking from **Play Store**? Would you like to enable automatic update checking from **GitHub**? + Update Source + GitHub + F-Droid Update Track Stable Beta @@ -275,6 +280,19 @@ New version available: %s Auto Update Automatically download and install updates in background + F-Droid Mirror + Auto Select by Latency + Testing… + %d ms + Failed + + Add Mirror + Name + URL + Custom + Invalid URL + Add + Delete Silent Install diff --git a/app/src/main/res/xml/cache_paths.xml b/app/src/main/res/xml/cache_paths.xml index c782c28..5f65e2f 100644 --- a/app/src/main/res/xml/cache_paths.xml +++ b/app/src/main/res/xml/cache_paths.xml @@ -1,6 +1,6 @@ - diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 1b0809c..32876c4 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -13,8 +13,10 @@ import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.update.checkFDroidUpdate object Vendor : VendorInterface { private const val TAG = "Vendor" @@ -93,19 +95,20 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true - override fun checkUpdateAsync(): UpdateInfo? { - val track = UpdateTrack.fromString(Settings.updateTrack) - return GitHubUpdateChecker().use { checker -> - checker.checkUpdate(track) + override val updateSources = listOf(UpdateSource.GITHUB, UpdateSource.FDROID) + + override fun checkUpdateAsync(): UpdateInfo? = when (UpdateSource.fromString(Settings.updateSource)) { + UpdateSource.FDROID -> checkFDroidUpdate(Application.application) + UpdateSource.GITHUB -> { + val track = UpdateTrack.fromString(Settings.updateTrack) + GitHubUpdateChecker().use { checker -> + checker.checkUpdate(track) + } } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index d34525d..835264d 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -93,7 +93,7 @@ object Vendor : VendorInterface { onCropArea: ((QRCodeCropArea?) -> Unit)?, ): ImageAnalysis.Analyzer? = null - override fun supportsTrackSelection(): Boolean = true + override val hasCustomUpdate = true override fun checkUpdateAsync(): UpdateInfo? { val track = UpdateTrack.fromString(Settings.updateTrack) @@ -102,10 +102,6 @@ object Vendor : VendorInterface { } } - override fun supportsSilentInstall(): Boolean = true - - override fun supportsAutoUpdate(): Boolean = true - override fun scheduleAutoUpdate() { UpdateWorker.schedule(io.nekohasekai.sfa.Application.application) } diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index daf9b5f..c8689ae 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -92,7 +92,5 @@ object Vendor : VendorInterface { } } - override fun supportsTrackSelection(): Boolean = false - override fun checkUpdateAsync(): UpdateInfo? = null } From 0d31ac467f4e62f325dffbc818207df3cb51a9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 11 Mar 2026 17:40:24 +0800 Subject: [PATCH 07/45] Bump 1.13.3-beta.1 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 5eee217..220bf1e 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=632 -VERSION_NAME=1.13.2 +VERSION_CODE=634 +VERSION_NAME=1.13.3-beta.1 GO_VERSION=go1.25.8 From d64e3b4235c5c55239553bfbd4dfde36bdd34d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 12 Mar 2026 22:27:48 +0800 Subject: [PATCH 08/45] Fix ParceledListSlice ClassLoader and RootClient service binding - Use proper ClassLoader in ParceledListSlice.createFromParcel instead of null - Add early root permission check in RootClient.bindService - Migrate RootService.bind to bindOrTask for proper error propagation --- .../nekohasekai/sfa/bg/ParceledListSlice.java | 2 +- .../java/io/nekohasekai/sfa/bg/RootClient.kt | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java index 9840067..60c824f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java @@ -136,7 +136,7 @@ public class ParceledListSlice implements Parcelable { new Parcelable.ClassLoaderCreator() { @Override public ParceledListSlice createFromParcel(Parcel in) { - return new ParceledListSlice(in, null); + return new ParceledListSlice(in, ParceledListSlice.class.getClassLoader()); } @Override diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index ab33003..735c7cd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -6,6 +6,7 @@ import android.content.ServiceConnection import android.content.pm.PackageInfo import android.os.IBinder import android.os.RemoteException +import androidx.core.content.ContextCompat import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.ipc.RootService import io.nekohasekai.sfa.Application @@ -17,7 +18,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.IOException import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException object RootClient { init { @@ -53,6 +56,10 @@ object RootClient { suspend fun bindService(): IRootService = connectionMutex.withLock { service?.let { return it } + if (Shell.isAppGrantedRoot() == false) { + throw IOException("permission denied") + } + return withContext(Dispatchers.Main) { suspendCancellableCoroutine { continuation -> val conn = object : ServiceConnection { @@ -72,7 +79,30 @@ object RootClient { } val intent = Intent(Application.application, RootServer::class.java) - RootService.bind(intent, conn) + val task = RootService.bindOrTask( + intent, + ContextCompat.getMainExecutor(Application.application), + conn, + ) + + if (task == null) { + // Already connected, onServiceConnected will fire + } else { + Shell.EXECUTOR.execute { + try { + val shell = Shell.getShell() + if (shell.isRoot) { + shell.execTask(task) + } else { + continuation.resumeWithException( + IOException("permission denied") + ) + } + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + } continuation.invokeOnCancellation { RootService.unbind(conn) From c2f1fd8e6761229c2c1b6b66a982fd464cbdd14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 12 Mar 2026 22:34:43 +0800 Subject: [PATCH 09/45] Fix DebugInfoExporter hanging --- .../nekohasekai/sfa/bg/DebugInfoExporter.kt | 65 +++++++++---------- .../java/io/nekohasekai/sfa/bg/RootClient.kt | 2 +- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt index a45c8d2..b304a70 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt @@ -13,11 +13,13 @@ import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream object DebugInfoExporter { private const val TAG = "DebugInfoExporter" + private const val BUFFER_SIZE = 128 * 1024 fun export(context: Context, outputPath: String, packageName: String): String { Log.i(TAG, "export start: output=$outputPath, package=$packageName") @@ -94,43 +96,27 @@ object DebugInfoExporter { private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList): Int { var count = 0 - val roots = - listOf( - File("/system/framework"), - File("/system_ext/framework"), - File("/product/framework"), - File("/vendor/framework"), - ) + val root = File("/system/framework") + if (!root.isDirectory) return 0 val targetFiles = setOf("framework.jar", "services.jar") - for (root in roots) { - if (!root.isDirectory) continue - val destPrefix = "framework/${root.name}" - val files = root.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (file.name !in targetFiles) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } + val files = root.listFiles() ?: emptyArray() + for (file in files) { + if (!file.isFile) continue + if (file.name !in targetFiles) continue + if (addFileEntry(zip, file, "framework/${file.name}", warnings, noCompression = true)) { + count++ } } return count } private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList): Int { - var count = 0 - val tetheringApex = File("/apex/com.android.tethering/javalib") - if (!tetheringApex.isDirectory) return 0 - val destPrefix = "framework/apex_com.android.tethering" - val files = tetheringApex.listFiles() ?: emptyArray() - for (file in files) { - if (!file.isFile) continue - if (!file.name.lowercase(Locale.US).endsWith(".jar")) continue - if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { - count++ - } + val file = File("/apex/com.android.tethering/javalib/service-connectivity.jar") + if (!file.isFile) { + warnings.add("missing file: ${file.path}") + return 0 } - return count + return if (addFileEntry(zip, file, "framework/apex_com.android.tethering/service-connectivity.jar", warnings, noCompression = true)) 1 else 0 } private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList, context: Context): Int { @@ -222,16 +208,22 @@ object DebugInfoExporter { return count } - private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList): Boolean { + private fun addFileEntry( + zip: ZipOutputStream, + file: File, + entryName: String, + warnings: MutableList, + noCompression: Boolean = false, + ): Boolean { if (!file.isFile) { warnings.add("missing file: ${file.path}") return false } try { - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) + if (noCompression) zip.setLevel(Deflater.NO_COMPRESSION) + zip.putNextEntry(ZipEntry(entryName)) BufferedInputStream(FileInputStream(file)).use { input -> - val buffer = ByteArray(16 * 1024) + val buffer = ByteArray(BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read <= 0) break @@ -239,9 +231,11 @@ object DebugInfoExporter { } } zip.closeEntry() + if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION) return true } catch (e: Throwable) { warnings.add("zip failed ${file.path}: ${e.message}") + if (noCompression) zip.setLevel(Deflater.DEFAULT_COMPRESSION) return false } } @@ -263,11 +257,10 @@ object DebugInfoExporter { command: List, ): CommandResult? = try { val process = ProcessBuilder(command).redirectErrorStream(true).start() - val entry = ZipEntry(entryName) - zip.putNextEntry(entry) + zip.putNextEntry(ZipEntry(entryName)) var bytes = 0L process.inputStream.use { input -> - val buffer = ByteArray(16 * 1024) + val buffer = ByteArray(BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read <= 0) break diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index 735c7cd..a7b24b9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -95,7 +95,7 @@ object RootClient { shell.execTask(task) } else { continuation.resumeWithException( - IOException("permission denied") + IOException("permission denied"), ) } } catch (e: Exception) { From 62c1b49c9e3cb3aea38bf5c7c8e31879b12be7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 12 Mar 2026 21:07:41 +0800 Subject: [PATCH 10/45] Fix ConnectivityService discovery on APEX-rewritten devices --- .../hidevpn/ConnectivityServiceHookHelper.kt | 81 +++++++++++++++++-- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt index 39b0b3b..3298b2b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt @@ -6,6 +6,7 @@ import android.net.Network import android.net.NetworkInfo import android.os.Build import android.os.IBinder +import android.os.Parcel import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedHelpers import io.nekohasekai.sfa.xposed.HookErrorStore @@ -26,6 +27,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo private val hooked = AtomicBoolean(false) private val initializerHooked = AtomicBoolean(false) private var classLoadUnhook: XC_MethodHook.Unhook? = null + private var onTransactUnhook: XC_MethodHook.Unhook? = null private val serviceManagerHooked = AtomicBoolean(false) private var connectivityClassLoader: ClassLoader = classLoader private val skipLogKeys = ConcurrentHashMap() @@ -53,6 +55,7 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } hookConnectivityServiceInitializer() hookClassLoaderFallback() + hookOnTransactFallback() tryHookFromServiceManager() } @@ -148,12 +151,39 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } } HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") + + val initializerNames = listOf( + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + ) + for (name in initializerNames) { + for (loader in loaders) { + val initCls = try { + if (loader != null) Class.forName(name, false, loader) else Class.forName(name) + } catch (_: Throwable) { + null + } ?: continue + try { + val field = initCls.getDeclaredField("mConnectivity") + val fieldType = field.type + if (fieldType.name.endsWith(".ConnectivityService")) { + HookErrorStore.i( + SOURCE, + "ConnectivityService class found via $name.mConnectivity: ${fieldType.name}", + ) + return fieldType + } + } catch (_: Throwable) { + } + } + } + return null } private fun hookConnectivityServiceInitializer() { - if (sdkInt < 31 || sdkInt >= 33) { - HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") + if (sdkInt < 31) { + HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (requires API 31+)") return } val candidates = listOf( @@ -238,20 +268,20 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo classLoadUnhook = null return } - when (name) { - "com.android.server.ConnectivityService" -> { + when { + name == "com.android.server.ConnectivityService" || + name.endsWith(".com.android.server.ConnectivityService") -> { val cls = param.result as? Class<*> ?: return HookErrorStore.i( SOURCE, - "ConnectivityService loaded via ${param.thisObject.javaClass.name}", + "ConnectivityService loaded via ${param.thisObject.javaClass.name}: $name", ) installHooks(cls, "loadClass") classLoadUnhook?.unhook() classLoadUnhook = null } - "com.android.server.ConnectivityServiceInitializer", - "com.android.server.ConnectivityServiceInitializerB", - -> { + name == "com.android.server.ConnectivityServiceInitializer" || + name == "com.android.server.ConnectivityServiceInitializerB" -> { if (sdkInt < 31) return if (initializerHooked.get()) return val cls = param.result as? Class<*> ?: return @@ -322,6 +352,41 @@ class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHoo } } + private fun hookOnTransactFallback() { + if (onTransactUnhook != null) return + try { + val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader) + onTransactUnhook = XposedHelpers.findAndHookMethod( + stub, + "onTransact", + Int::class.javaPrimitiveType, + Parcel::class.java, + Parcel::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + if (hooked.get()) { + onTransactUnhook?.unhook() + onTransactUnhook = null + return + } + val serviceClass = param.thisObject.javaClass + HookErrorStore.i( + SOURCE, + "ConnectivityService discovered via onTransact: ${serviceClass.name}", + ) + installHooks(serviceClass, "onTransact") + onTransactUnhook?.unhook() + onTransactUnhook = null + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.Stub.onTransact for discovery") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook onTransact fallback failed: ${e.message}", e) + } + } + private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { if (sdkInt < 31) return if (initializerHooked.get()) return From 8ba9fe2548604e5b6e8cbbfec19da663867facf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 15 Mar 2026 14:07:28 +0800 Subject: [PATCH 11/45] Fix config file path collision --- .../compose/screen/configuration/ProfileImportHandler.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt index 0a8e9a5..c89532e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt @@ -241,8 +241,9 @@ class ProfileImportHandler(private val context: Context) { } // Save config file + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText(content.config) typedProfile.path = configFile.path @@ -268,8 +269,9 @@ class ProfileImportHandler(private val context: Context) { } // Create empty config file for remote profile + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText("{}") typedProfile.path = configFile.path @@ -370,8 +372,9 @@ class ProfileImportHandler(private val context: Context) { } // Save the configuration file + val fileID = ProfileManager.nextFileID() val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") + val configFile = File(configDirectory, "$fileID.json") configFile.writeText(jsonContent) typedProfile.path = configFile.path From 6f09892c7193c696b9fc182123db8051562cdf74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 15 Mar 2026 17:00:03 +0800 Subject: [PATCH 12/45] Bump 1.13.3 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 220bf1e..1a91032 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=634 -VERSION_NAME=1.13.3-beta.1 +VERSION_CODE=636 +VERSION_NAME=1.13.3 GO_VERSION=go1.25.8 From a7caf965a0a383fb5fbf68c3006b0f2240b52a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 16 Mar 2026 18:54:50 +0800 Subject: [PATCH 13/45] Fix FileProvider unable to resolve cacheDir paths --- app/src/main/res/xml/cache_paths.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/xml/cache_paths.xml b/app/src/main/res/xml/cache_paths.xml index 5f65e2f..e1d9237 100644 --- a/app/src/main/res/xml/cache_paths.xml +++ b/app/src/main/res/xml/cache_paths.xml @@ -1,5 +1,8 @@ + From b3515329c24c207ee1616e0c9b9456751692c3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 16 Mar 2026 19:31:25 +0800 Subject: [PATCH 14/45] Fix profile navigation --- .../screen/profile/EditProfileContentScreen.kt | 3 +-- .../screen/profile/EditProfileContentViewModel.kt | 8 +++----- .../compose/screen/profile/EditProfileRoute.kt | 15 +++++---------- .../compose/screen/profile/EditProfileScreen.kt | 3 +-- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt index 1c883d0..def7b1f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt @@ -92,12 +92,11 @@ fun EditProfileContentScreen( profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, - profileName: String = "", isReadOnly: Boolean = false, ) { val viewModel: EditProfileContentViewModel = viewModel( - factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly), + factory = EditProfileContentViewModel.Factory(profileId, isReadOnly), ) val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt index 1c19a9b..51a082e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt @@ -38,11 +38,10 @@ data class EditProfileContentUiState( val profileName: String = "", // Add profile name ) -class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() { +class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly: Boolean = false) : ViewModel() { private val _uiState = MutableStateFlow( EditProfileContentUiState( - profileName = initialProfileName, isReadOnly = initialIsReadOnly, ), ) @@ -211,7 +210,7 @@ class EditProfileContentViewModel(private val profileId: Long, initialProfileNam originalContent = content, hasUnsavedChanges = false, isLoading = false, - // Keep profileName and isReadOnly from initial state - no need to update + profileName = loadedProfile.name, ) } } @@ -584,13 +583,12 @@ class EditProfileContentViewModel(private val profileId: Long, initialProfileNam class Factory( private val profileId: Long, - private val initialProfileName: String = "", private val initialIsReadOnly: Boolean = false, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { - return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T + return EditProfileContentViewModel(profileId, initialIsReadOnly) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt index b617b43..a9a8cbc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt @@ -10,6 +10,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import android.net.Uri import androidx.navigation.navArgument @Composable @@ -64,12 +65,12 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi profileId = profileId, onNavigateBack = onNavigateBack, onNavigateToIconSelection = { currentIconId -> - navController.navigate("icon_selection/${currentIconId ?: "null"}") { + navController.navigate("icon_selection/${Uri.encode(currentIconId ?: "null")}") { launchSingleTop = true } }, - onNavigateToEditContent = { profileName, isReadOnly -> - navController.navigate("edit_content/$profileName/$isReadOnly") { + onNavigateToEditContent = { isReadOnly -> + navController.navigate("edit_content/$isReadOnly") { launchSingleTop = true } }, @@ -128,13 +129,9 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi } composable( - route = "edit_content/{profileName}/{isReadOnly}", + route = "edit_content/{isReadOnly}", arguments = listOf( - navArgument("profileName") { - type = NavType.StringType - defaultValue = "" - }, navArgument("isReadOnly") { type = NavType.BoolType defaultValue = false @@ -165,7 +162,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi ) }, ) { backStackEntry -> - val profileName = backStackEntry.arguments?.getString("profileName") ?: "" val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false EditProfileContentScreen( @@ -173,7 +169,6 @@ fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modi onNavigateBack = { navController.popBackStack("edit_profile", inclusive = false) }, - profileName = profileName, isReadOnly = isReadOnly, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt index 84af0e0..d6a92fd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt @@ -79,7 +79,7 @@ fun EditProfileScreen( profileId: Long, onNavigateBack: () -> Unit, onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, - onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> }, + onNavigateToEditContent: (isReadOnly: Boolean) -> Unit = {}, viewModel: EditProfileViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -473,7 +473,6 @@ fun EditProfileScreen( .clip(RoundedCornerShape(12.dp)) .clickable { onNavigateToEditContent( - uiState.name, uiState.profileType == TypedProfile.Type.Remote, ) }, From 3692d54420590be093899608344a65b42590cae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 16 Mar 2026 18:38:00 +0800 Subject: [PATCH 15/45] Add Allow Bypass VPN setting to service screen Add a toggle in the service settings screen that calls VpnService.Builder.allowBypass() when enabled, with a description linking to Android documentation. Always show the Service item in the settings list (remove battery-optimization-gated visibility). --- .../java/io/nekohasekai/sfa/bg/VPNService.kt | 4 + .../screen/settings/ServiceSettingsScreen.kt | 109 +++++++++++++++++- .../compose/screen/settings/SettingsScreen.kt | 57 ++++----- .../nekohasekai/sfa/constant/SettingsKey.kt | 1 + .../io/nekohasekai/sfa/database/Settings.kt | 1 + app/src/main/res/values-fa/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 10 files changed, 145 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index d958184..6b4c814 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -66,6 +66,10 @@ class VPNService : builder.setMetered(false) } + if (Settings.allowBypass) { + builder.allowBypass() + } + val inet4Address = options.inet4Address while (inet4Address.hasNext()) { val address = inet4Address.next() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index 7f89fff..b6038d9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -26,8 +28,11 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -35,18 +40,28 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.launchCustomTab +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,14 +81,13 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi } val context = LocalContext.current - // Check battery optimization status + val scope = rememberCoroutineScope() var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } - // Activity result launcher for battery optimization permission + var allowBypass by remember { mutableStateOf(Settings.allowBypass) } val requestBatteryOptimizationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { _ -> - // Recheck the status after returning from settings if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = context.getSystemService(PowerManager::class.java) isBatteryOptimizationIgnored = @@ -81,7 +95,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi } } - // Check battery optimization status on launch LaunchedEffect(Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val pm = context.getSystemService(PowerManager::class.java) @@ -100,7 +113,6 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { - // Background Permission Card (only show if battery optimization is not ignored) if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Card( modifier = @@ -171,6 +183,93 @@ 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 + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + Spacer(modifier = Modifier.height(16.dp)) } } + +private const val ALLOW_BYPASS_DOC_URL = + "https://developer.android.com/reference/android/net/VpnService.Builder#allowBypass()" diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index efe94dd..b7442c9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -1,7 +1,5 @@ package io.nekohasekai.sfa.compose.screen.settings -import android.os.Build -import android.os.PowerManager import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -37,10 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -70,15 +65,8 @@ fun SettingsScreen(navController: NavController) { val hookStatus by HookStatusClient.status.collectAsState() val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) - var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { HookStatusClient.refresh() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val pm = context.getSystemService(PowerManager::class.java) - isBatteryOptimizationIgnored = - pm?.isIgnoringBatteryOptimizations(context.packageName) == true - } } Column( @@ -153,31 +141,26 @@ fun SettingsScreen(navController: NavController) { ), ) - if (!isBatteryOptimizationIgnored) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.service), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Badge(containerColor = MaterialTheme.colorScheme.primary) - }, - modifier = Modifier.clickable { navController.navigate("settings/service") }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) - } + ListItem( + headlineContent = { + Text( + stringResource(R.string.service), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clickable { navController.navigate("settings/service") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) ListItem( headlineContent = { diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index f34199c..2f41fea 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -23,6 +23,7 @@ object SettingsKey { const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" + const val ALLOW_BYPASS = "allow_bypass" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index ed37cb6..64f417f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -96,6 +96,7 @@ object Settings { perAppProxyList } + var allowBypass by dataStore.boolean(SettingsKey.ALLOW_BYPASS) { false } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 14e6cc8..4989819 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -204,6 +204,9 @@ نمایش سرعت بلادرنگ در اعلان به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس دسته‌بندی اعلان را در تنظیمات غیرفعال کنید. به دلیل محدودیت‌های اندروید، ابتدا باید مجوز اعلان را بدهید، سپس اعلان‌ها را در اطلاعات برنامه غیرفعال کنید. + اجازه دور زدن VPN + در صورت فعال بودن، برنامه‌ها می‌توانند این اتصال VPN را دور بزنند و مستقیماً از شبکه اصلی استفاده کنند. + مستندات Android تغییر مسیر خودکار نیازمند دسترسی ROOT پراکسی HTTP سیستم diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index ae2aa0d..3e9fca6 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -204,6 +204,9 @@ Отображать скорость в реальном времени в уведомлении Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить категорию уведомлений в настройках. Из-за ограничений Android необходимо сначала предоставить разрешение на уведомления, а затем отключить уведомления в сведениях о приложении. + Разрешить обход VPN + Если включено, приложения могут обойти это VPN-соединение и использовать базовую сеть напрямую. + Документация Android Автоматическое перенаправление Требуются права ROOT Системный HTTP-прокси diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 451032f..7ca82f6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -206,6 +206,9 @@ 在通知中显示实时网速 由于 Android 限制,您需要先授权通知权限,然后前往系统设置中关闭通知类别。 由于 Android 限制,您需要先授权通知权限,然后前往应用信息中关闭通知。 + 允许绕过 VPN + 启用后,应用可以绕过此 VPN 连接,直接使用底层网络。 + Android 文档 自动重定向 需要 ROOT 权限 系统 HTTP 代理 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 70e5295..554892d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -206,6 +206,9 @@ 在通知中顯示即時網速 由於 Android 限制,您需要先授權通知權限,然後前往系統設定中關閉通知類別。 由於 Android 限制,您需要先授權通知權限,然後前往應用程式資訊中關閉通知。 + 允許繞過 VPN + 啟用後,應用程式可以繞過此 VPN 連線,直接使用底層網路。 + Android 文件 自動重定向 需要 ROOT 權限 系統 HTTP 代理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63a3787..8d020cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,6 +206,9 @@ Display realtime speed in notification Due to Android restrictions, you must first grant notification permission, then go to Settings to disable the notification category. Due to Android restrictions, you must first grant notification permission, then go to App Info to disable notifications. + Allow Bypass + If enabled, applications can bypass this VPN connection and instead use the underlying network directly. + Android Documentation Auto Redirect ROOT permission required System HTTP Proxy From cf145b53740719c1743a0b5b35240431b8194174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 21 Mar 2026 12:40:41 +0800 Subject: [PATCH 16/45] Dispatch log callbacks to main thread Move allLogs/bufferedLogs mutations in appendLogs, clearLogs, and setDefaultLogLevel into viewModelScope.launch(Dispatchers.Main) to avoid concurrent iteration from the search debounce flow. --- .../sfa/compose/screen/log/LogViewModel.kt | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt index 601a51d..ecaf56d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -81,15 +81,19 @@ class LogViewModel : override fun setDefaultLogLevel(level: Int) { val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") - _uiState.update { it.copy(defaultLogLevel = logLevel) } - updateDisplayedLogs() + viewModelScope.launch(Dispatchers.Main) { + _uiState.update { it.copy(defaultLogLevel = logLevel) } + updateDisplayedLogs() + } } override fun clearLogs() { - allLogs.clear() - bufferedLogs.clear() - _uiState.update { it.copy(isPaused = false) } - updateDisplayedLogs() + viewModelScope.launch(Dispatchers.Main) { + allLogs.clear() + bufferedLogs.clear() + _uiState.update { it.copy(isPaused = false) } + updateDisplayedLogs() + } } override fun requestClearLogs() { @@ -104,23 +108,25 @@ class LogViewModel : override fun appendLogs(message: List) { val processedLogs = message.map { processLogEntry(it) } - if (_uiState.value.isPaused) { - bufferedLogs.addAll(processedLogs) - } else { - val totalSize = allLogs.size + processedLogs.size - val removeCount = (totalSize - maxLines).coerceAtLeast(0) + viewModelScope.launch(Dispatchers.Main) { + if (_uiState.value.isPaused) { + bufferedLogs.addAll(processedLogs) + } else { + val totalSize = allLogs.size + processedLogs.size + val removeCount = (totalSize - maxLines).coerceAtLeast(0) - if (removeCount > 0) { - repeat(removeCount) { - allLogs.removeFirst() + if (removeCount > 0) { + repeat(removeCount) { + allLogs.removeFirst() + } } - } - allLogs.addAll(processedLogs) - updateDisplayedLogs() + allLogs.addAll(processedLogs) + updateDisplayedLogs() - if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { - scrollToBottom() + if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { + scrollToBottom() + } } } } From 330eaee346a61cf93c6c01c988046fd760572b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 23 Mar 2026 18:25:49 +0800 Subject: [PATCH 17/45] Pass all package names for shared UID connections --- .../io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt | 2 +- .../java/io/nekohasekai/sfa/compose/model/Connection.kt | 8 ++++---- .../compose/screen/connections/ConnectionDetailsScreen.kt | 6 +++--- .../sfa/compose/screen/connections/ConnectionItem.kt | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index fa7cea5..7a0be3c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -58,7 +58,7 @@ interface PlatformInterfaceWrapper : PlatformInterface { val owner = ConnectionOwner() owner.userId = uid owner.userName = packages?.firstOrNull() ?: "" - owner.androidPackageName = packages?.firstOrNull() ?: "" + owner.setAndroidPackageNames(StringArray(packages?.toList()?.iterator() ?: emptyList().iterator())) return owner } catch (e: Exception) { Log.e("PlatformInterface", "getConnectionOwnerUid", e) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt b/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt index c84759e..ee5953a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt @@ -6,7 +6,7 @@ import io.nekohasekai.libbox.Connection as LibboxConnection import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo @Immutable -data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) { +data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageNames: List) { companion object { fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { if (processInfo == null) return null @@ -15,7 +15,7 @@ data class ProcessInfo(val processId: Long, val userId: Int, val userName: Strin userId = processInfo.userID, userName = processInfo.userName ?: "", processPath = processInfo.processPath ?: "", - packageName = processInfo.packageName ?: "", + packageNames = processInfo.packageNames()?.toList() ?: emptyList(), ) } } @@ -66,7 +66,7 @@ data class Connection( domain.contains(content, ignoreCase = true) || outbound.contains(content, ignoreCase = true) || rule.contains(content, ignoreCase = true) || - processInfo?.packageName?.contains(content, ignoreCase = true) == true + processInfo?.packageNames?.any { it.contains(content, ignoreCase = true) } == true private fun performSearchType(type: String, value: String): Boolean = when (type) { "network" -> network.equals(value, ignoreCase = true) @@ -79,7 +79,7 @@ data class Connection( "rule" -> rule.contains(value, ignoreCase = true) "protocol" -> protocolName.equals(value, ignoreCase = true) "user" -> user.contains(value, ignoreCase = true) - "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true + "package" -> processInfo?.packageNames?.any { it.contains(value, ignoreCase = true) } == true "chain" -> chain.any { it.contains(value, ignoreCase = true) } else -> false } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt index ef57240..bb7548d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -247,7 +247,7 @@ fun ConnectionDetailsScreen( } connection.processInfo?.let { processInfo -> - if (processInfo.packageName.isNotEmpty() || + if (processInfo.packageNames.isNotEmpty() || processInfo.processPath.isNotEmpty() || processInfo.processId > 0 ) { @@ -282,10 +282,10 @@ fun ConnectionDetailsScreen( monospace = true, ) } - if (processInfo.packageName.isNotEmpty()) { + if (processInfo.packageNames.isNotEmpty()) { DetailRow( label = stringResource(R.string.connection_package_name), - value = processInfo.packageName, + value = processInfo.packageNames.joinToString(", "), monospace = true, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt index 49dc7a0..a525ad5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -82,7 +82,7 @@ private fun rememberAppInfo(packageName: String): AppInfo? { @Composable fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { var showContextMenu by remember { mutableStateOf(false) } - val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() } + val packageName = connection.processInfo?.packageNames?.firstOrNull() val appInfo = packageName?.let { rememberAppInfo(it) } Box(modifier = modifier) { From 2cb198708006e9a5c6a5f7f2b67c859ab356e936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 23 Mar 2026 19:52:23 +0800 Subject: [PATCH 18/45] Fix resource leaks in service startup error cleanup --- .../main/java/io/nekohasekai/sfa/bg/BoxService.kt | 13 +++++++++++-- .../sfa/compose/screen/profile/EditProfileRoute.kt | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index 1761e65..a354866 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -162,7 +162,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl android.Manifest.permission.ACCESS_BACKGROUND_LOCATION } if (!service.hasPermission(wifiPermission)) { - closeService() stopAndAlert(Alert.RequestLocationPermission) return } @@ -243,7 +242,6 @@ class BoxService(private val service: Service, private val platformInterface: Pl android.Manifest.permission.ACCESS_BACKGROUND_LOCATION } if (!service.hasPermission(wifiPermission)) { - closeService() stopAndAlert(Alert.RequestLocationPermission) return } @@ -311,6 +309,16 @@ class BoxService(private val service: Service, private val platformInterface: Pl private suspend fun stopAndAlert(type: Alert, message: String? = null) { Settings.startedByUser = false + val pfd = fileDescriptor + if (pfd != null) { + pfd.close() + fileDescriptor = null + } + DefaultNetworkMonitor.stop() + if (::commandServer.isInitialized) { + closeService() + commandServer.close() + } withContext(Dispatchers.Main) { if (receiverRegistered) { service.unregisterReceiver(receiver) @@ -321,6 +329,7 @@ class BoxService(private val service: Service, private val platformInterface: Pl callback.onServiceAlert(type.ordinal, message) } status.value = Status.Stopped + service.stopSelf() } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt index a9a8cbc..424319d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.compose.screen.profile +import android.net.Uri import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable @@ -10,7 +11,6 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import android.net.Uri import androidx.navigation.navArgument @Composable From f6c225df65ea1f7c6cfeb1ee088f199e39e43ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 21:00:25 +0800 Subject: [PATCH 19/45] Fix command client connect crash --- .../main/java/io/nekohasekai/sfa/utils/CommandClient.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index c5b7681..3d5abf9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -100,7 +100,12 @@ open class CommandClient( } options.statusInterval = 1 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) - commandClient.connect() + try { + commandClient.connect() + } catch (e: Exception) { + Log.d("CommandClient", "connect failed", e) + return + } this.commandClient = commandClient } From 834a5f7df08bedb24dc361781959459e0550222f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 23 Mar 2026 19:43:36 +0800 Subject: [PATCH 20/45] Bump version 1.13.4 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 1a91032..1caee2f 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=636 -VERSION_NAME=1.13.3 +VERSION_CODE=644 +VERSION_NAME=1.13.4 GO_VERSION=go1.25.8 From 080488932fc454ed006d6e85391bd3fb1fc629aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 30 Mar 2026 22:40:29 +0800 Subject: [PATCH 21/45] Add download progress --- .../nekohasekai/sfa/vendor/ApkDownloader.kt | 11 +++++++++- .../nekohasekai/sfa/compose/MainActivity.kt | 22 +++++++++++++++---- .../screen/settings/AppSettingsScreen.kt | 22 +++++++++++++++---- .../io/nekohasekai/sfa/update/UpdateState.kt | 3 +++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt b/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt index 98ce882..d90e2c7 100644 --- a/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt +++ b/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.vendor +import io.nekohasekai.libbox.HTTPResponseWriteToProgressHandler import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.update.UpdateState @@ -27,7 +28,15 @@ class ApkDownloader : Closeable { request.setURL(url) val response = request.execute() - response.writeTo(apkFile.absolutePath) + response.writeToWithProgress( + apkFile.absolutePath, + object : HTTPResponseWriteToProgressHandler { + override fun update(progress: Long, total: Long) { + UpdateState.downloadProgress.value = + if (total > 0) progress.toFloat() / total.toFloat() else null + } + }, + ) if (!apkFile.exists() || apkFile.length() == 0L) { throw Exception("Download failed: empty file") diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 574422b..249d12c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar @@ -565,10 +566,22 @@ class MainActivity : color = MaterialTheme.colorScheme.error, ) } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) + val progress by UpdateState.downloadProgress + Column { + if (progress != null) { + Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%") + } else { + Text(stringResource(R.string.downloading)) + } + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + LinearProgressIndicator( + progress = { progress!! }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } } @@ -580,6 +593,7 @@ class MainActivity : downloadJob = null showDownloadDialog = false downloadError = null + UpdateState.downloadProgress.value = null }, ) { Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 99c82d2..3a71f46 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -261,10 +262,22 @@ fun AppSettingsScreen(navController: NavController) { color = MaterialTheme.colorScheme.error, ) } else { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) - Text(stringResource(R.string.downloading)) + val progress by UpdateState.downloadProgress + Column { + if (progress != null) { + Text("${stringResource(R.string.downloading)} ${(progress!! * 100).toInt()}%") + } else { + Text(stringResource(R.string.downloading)) + } + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + LinearProgressIndicator( + progress = { progress!! }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } } @@ -276,6 +289,7 @@ fun AppSettingsScreen(navController: NavController) { downloadJob = null showDownloadDialog = false downloadError = null + UpdateState.downloadProgress.value = null }, ) { Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt index 17efa3d..19ba94e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt @@ -11,6 +11,7 @@ object UpdateState { val isChecking = mutableStateOf(false) val isDownloading = mutableStateOf(false) + val downloadProgress = mutableStateOf(null) val downloadError = mutableStateOf(null) val cachedApkFile = mutableStateOf(null) @@ -38,6 +39,7 @@ object UpdateState { hasUpdate.value = false updateInfo.value = null isDownloading.value = false + downloadProgress.value = null downloadError.value = null installStatus.value = InstallStatus.Idle cachedApkFile.value = null @@ -46,6 +48,7 @@ object UpdateState { fun resetDownload() { isDownloading.value = false + downloadProgress.value = null downloadError.value = null } From cba1cc3ce028e7954342fffe2245cb5366138d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 30 Mar 2026 22:43:28 +0800 Subject: [PATCH 22/45] Bump version 1.13.5 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 1caee2f..0b694ea 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=644 -VERSION_NAME=1.13.4 +VERSION_CODE=646 +VERSION_NAME=1.13.5 GO_VERSION=go1.25.8 From 4f0826b94d59e96a7a35d00891f2e0ad2c72123d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 6 Apr 2026 23:08:25 +0800 Subject: [PATCH 23/45] Bump version 1.13.6 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 0b694ea..dd3c6f9 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=646 -VERSION_NAME=1.13.5 +VERSION_CODE=649 +VERSION_NAME=1.13.6 GO_VERSION=go1.25.8 From 9752f4845f57cfd1258c49a3a374d90cf4a7ed68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 16:21:48 +0800 Subject: [PATCH 24/45] Fix notification --- app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 249d12c..60441e3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -114,6 +114,7 @@ import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController import io.nekohasekai.sfa.compose.topbar.TopBarEntry +import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.ServiceMode import io.nekohasekai.sfa.constant.Status @@ -226,6 +227,10 @@ class MainActivity : pendingNavigationRoute.value = "settings/privilege" } val uri = intent.data ?: return + if (intent.action == Action.OPEN_URL) { + launchCustomTab(uri.toString()) + return + } if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { try { val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) From fea0f3a7ba5921d87ddd11902be3c148a61fc689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 15:31:35 +0800 Subject: [PATCH 25/45] Bump version 1.13.7 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index dd3c6f9..a47c52e 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=649 -VERSION_NAME=1.13.6 +VERSION_CODE=651 +VERSION_NAME=1.13.7 GO_VERSION=go1.25.8 From ab09918615e3fbfb6f2f17a06f1b49448dcccbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 14 Apr 2026 14:30:55 +0800 Subject: [PATCH 26/45] Bump version 1.13.8 --- version.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/version.properties b/version.properties index a47c52e..001047c 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=651 -VERSION_NAME=1.13.7 -GO_VERSION=go1.25.8 +VERSION_CODE=654 +VERSION_NAME=1.13.8 +GO_VERSION=go1.25.9 From 509ef85646eed01e701af2f5ef0d6636ce22c988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 11 Apr 2026 12:02:46 +0800 Subject: [PATCH 27/45] Fix deprecated check error --- .../sfa/compose/screen/dashboard/DashboardViewModel.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index 943eca0..32f2ed0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -200,7 +200,7 @@ class DashboardViewModel : private fun checkDeprecatedNotes() { viewModelScope.launch(Dispatchers.IO) { - try { + runCatching { // Check if deprecated warnings are disabled if (Settings.disableDeprecatedWarnings) { return@launch @@ -227,8 +227,6 @@ class DashboardViewModel : } } } - } catch (e: Exception) { - sendError(e) } } } From 1b8f348fe1e6d0d3a874635703ac5c5475b6d8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Apr 2026 15:45:46 +0800 Subject: [PATCH 28/45] Add compatibility for the new version of LSPosed --- .../nekohasekai/sfa/xposed/HookInstaller.kt | 49 +++++++++++++++++++ .../io/nekohasekai/sfa/xposed/XposedInit.kt | 40 +-------------- .../nekohasekai/sfa/xposed/XposedInit101.kt | 11 +++++ .../resources/META-INF/xposed/java_init.list | 1 + .../resources/META-INF/xposed/module.prop | 2 +- .../libxposed/api/XposedInterfaceWrapper.java | 14 +++++- .../io/github/libxposed/api/XposedModule.java | 15 ++++-- .../libxposed/api/XposedModuleInterface.java | 43 +++++++++++++++- 8 files changed, 126 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt new file mode 100644 index 0000000..08d4c2b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/HookInstaller.kt @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.xposed + +import android.content.Context +import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact +import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper +import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel +import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName +import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages + +object HookInstaller { + + private const val TAG = "XposedInit" + + private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") } + private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") } + private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") } + + fun install(classLoader: ClassLoader) { + val systemContext = resolveSystemContext() + HookErrorStore.i(TAG, "handleSystemServerLoaded") + val hooks = arrayOf( + ConnectivityServiceHookHelper(classLoader), + HookIConnectivityManagerOnTransact(classLoader, systemContext), + HookPackageManagerGetInstalledPackages(classLoader), + HookNetworkCapabilitiesWriteToParcel(), + HookNetworkInterfaceGetName(classLoader), + ) + + hooks.forEach { hook -> + try { + hook.injectHook() + } catch (e: Throwable) { + HookErrorStore.e( + TAG, + "Failed to inject ${hook.javaClass.simpleName}", + e, + ) + } + } + } + + private fun resolveSystemContext(): Context? = try { + val currentThread = currentActivityThreadMethod.invoke(null) + getSystemContextMethod.invoke(currentThread) as? Context + } catch (e: Throwable) { + HookErrorStore.e(TAG, "resolveSystemContext failed", e) + null + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt index 10fe4a2..522da95 100644 --- a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt @@ -1,54 +1,16 @@ package io.nekohasekai.sfa.xposed -import android.content.Context import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface -import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact -import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel -import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName -import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { - private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") } - private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") } - private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") } - override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { - val systemContext = resolveSystemContext() - HookErrorStore.i("XposedInit", "handleSystemServerLoaded") - val hooks = arrayOf( - ConnectivityServiceHookHelper(param.classLoader), - HookIConnectivityManagerOnTransact(param.classLoader, systemContext), - HookPackageManagerGetInstalledPackages(param.classLoader), - HookNetworkCapabilitiesWriteToParcel(), - HookNetworkInterfaceGetName(param.classLoader), - ) - - hooks.forEach { hook -> - try { - hook.injectHook() - } catch (e: Throwable) { - HookErrorStore.e( - "XposedInit", - "Failed to inject ${hook.javaClass.simpleName}", - e, - ) - } - } + HookInstaller.install(param.classLoader) } companion object { const val TAG = "sing-box-lsposed" } - - private fun resolveSystemContext(): Context? = try { - val currentThread = currentActivityThreadMethod.invoke(null) - getSystemContextMethod.invoke(currentThread) as? Context - } catch (e: Throwable) { - HookErrorStore.e("XposedInit", "resolveSystemContext failed", e) - null - } } diff --git a/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt new file mode 100644 index 0000000..e5504a8 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit101.kt @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.xposed + +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface + +class XposedInit101 : XposedModule() { + + override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { + HookInstaller.install(param.classLoader) + } +} diff --git a/app/src/main/resources/META-INF/xposed/java_init.list b/app/src/main/resources/META-INF/xposed/java_init.list index 54a7373..06d0239 100644 --- a/app/src/main/resources/META-INF/xposed/java_init.list +++ b/app/src/main/resources/META-INF/xposed/java_init.list @@ -1 +1,2 @@ io.nekohasekai.sfa.xposed.XposedInit +io.nekohasekai.sfa.xposed.XposedInit101 diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop index 8dc7ff3..ec34252 100644 --- a/app/src/main/resources/META-INF/xposed/module.prop +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -1,3 +1,3 @@ minApiVersion=100 -targetApiVersion=100 +targetApiVersion=101 staticScope=true diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java index 425596f..71eba22 100644 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedInterfaceWrapper.java @@ -21,9 +21,19 @@ import io.github.libxposed.api.utils.DexParser; */ public class XposedInterfaceWrapper implements XposedInterface { - private final XposedInterface mBase; + private volatile XposedInterface mBase; - XposedInterfaceWrapper(@NonNull XposedInterface base) { + public XposedInterfaceWrapper() { + } + + public XposedInterfaceWrapper(@NonNull XposedInterface base) { + mBase = base; + } + + public final void attachFramework(@NonNull XposedInterface base) { + if (mBase != null) { + throw new IllegalStateException("Framework already attached"); + } mBase = base; } diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java index b2e1a03..0c8c755 100644 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModule.java @@ -9,11 +9,16 @@ import androidx.annotation.NonNull; @SuppressWarnings("unused") public abstract class XposedModule extends XposedInterfaceWrapper implements XposedModuleInterface { /** - * Instantiates a new Xposed module.
- * When the module is loaded into the target process, the constructor will be called. - * - * @param base The implementation interface provided by the framework, should not be used by the module - * @param param Information about the process in which the module is loaded + * No-arg constructor for API 101 contract: the framework instantiates the module via + * {@code Class.getDeclaredConstructor()}, then calls {@link #attachFramework}. + */ + public XposedModule() { + super(); + } + + /** + * Two-arg constructor for API 100 contract: the framework instantiates the module via + * {@code (XposedInterface, ModuleLoadedParam)} and attaches the framework base inline. */ public XposedModule(@NonNull XposedInterface base, @NonNull ModuleLoadedParam param) { super(base); diff --git a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java index 1cb548c..953edac 100644 --- a/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java +++ b/third_party/libxposed-api/src/main/java/io/github/libxposed/api/XposedModuleInterface.java @@ -1,5 +1,6 @@ package io.github.libxposed.api; +import android.app.AppComponentFactory; import android.content.pm.ApplicationInfo; import android.os.Build; @@ -32,7 +33,7 @@ public interface XposedModuleInterface { } /** - * Wraps information about system server. + * Wraps information about system server. API 100 flavor. */ interface SystemServerLoadedParam { /** @@ -44,6 +45,26 @@ public interface XposedModuleInterface { ClassLoader getClassLoader(); } + /** + * Wraps information about system server. API 101 flavor. + */ + interface SystemServerStartingParam { + @NonNull + ClassLoader getClassLoader(); + } + + /** + * Wraps information about a package whose classloader is ready. API 101. + */ + interface PackageReadyParam extends PackageLoadedParam { + @NonNull + ClassLoader getClassLoader(); + + @RequiresApi(Build.VERSION_CODES.P) + @NonNull + AppComponentFactory getAppComponentFactory(); + } + /** * Wraps information about the package being loaded. */ @@ -99,10 +120,28 @@ public interface XposedModuleInterface { } /** - * Gets notified when the system server is loaded. + * Gets notified when the system server is loaded. API 100. * * @param param Information about system server */ default void onSystemServerLoaded(@NonNull SystemServerLoadedParam param) { } + + /** + * API 101: invoked once per process after the module instance is attached. + */ + default void onModuleLoaded(@NonNull ModuleLoadedParam param) { + } + + /** + * API 101: invoked when a package's classloader is ready. + */ + default void onPackageReady(@NonNull PackageReadyParam param) { + } + + /** + * API 101: replaces {@link #onSystemServerLoaded(SystemServerLoadedParam)}. + */ + default void onSystemServerStarting(@NonNull SystemServerStartingParam param) { + } } From dfe64c11d27c709deb33185f47ec5e8943a62ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Apr 2026 21:58:30 +0800 Subject: [PATCH 29/45] Fix back gesture from connection details skipping list On phones, the Connections list and Connection Details share a single ModalBottomSheet. System back was consumed by the sheet's default dismiss handler, closing the sheet and returning to Dashboard. The in-app back button just cleared selectedConnectionId, returning to the list. Add a BackHandler that runs only while a connection is selected, matching the in-app back button behavior. Swipe-to-dismiss remains unchanged. --- app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 60441e3..56e8639 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.net.VpnService import android.os.Build import android.os.Bundle +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -1107,6 +1108,10 @@ class MainActivity : } } + BackHandler(enabled = selectedConnectionId != null) { + selectedConnectionId = null + } + ModalBottomSheet( onDismissRequest = { showConnectionsSheet = false From ea63fb0f8b3d62697b13da2a7cf6fedf1173c2ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Apr 2026 22:39:11 +0800 Subject: [PATCH 30/45] Fix checkDefaultInterfaceUpdate --- .../io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt index 3c02e04..87fc729 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -43,17 +43,20 @@ object DefaultNetworkMonitor { private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { val listener = listener ?: return if (newNetwork != null) { - val interfaceName = - (Application.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName for (times in 0 until 10) { + val linkProperties = Application.connectivity.getLinkProperties(newNetwork) + if (linkProperties == null) { + Thread.sleep(100) + continue + } var interfaceIndex: Int try { - interfaceIndex = NetworkInterface.getByName(interfaceName).index + interfaceIndex = NetworkInterface.getByName(linkProperties.interfaceName).index } catch (e: Exception) { Thread.sleep(100) continue } - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) + listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, false, false) } } else { listener.updateDefaultInterface("", -1, false, false) From 2d7efc04a7b078035562cd408b7ddc87d83f6d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Apr 2026 23:25:45 +0800 Subject: [PATCH 31/45] Add long-press copy on App and Core version items --- .../screen/settings/AppSettingsScreen.kt | 106 ++++++++++++------ .../screen/settings/CoreSettingsScreen.kt | 99 +++++++++++----- 2 files changed, 143 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 3a71f46..9c94f63 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -9,9 +9,13 @@ import android.net.Uri import android.os.Build import android.text.format.Formatter import android.util.Log +import android.widget.Toast import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,6 +30,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.DeleteForever @@ -44,6 +49,8 @@ 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 @@ -83,6 +90,7 @@ import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateSource import io.nekohasekai.sfa.update.UpdateState @@ -99,7 +107,7 @@ import java.io.File import java.util.Locale import android.provider.Settings as AndroidSettings -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun AppSettingsScreen(navController: NavController) { OverrideTopBar { @@ -142,6 +150,7 @@ fun AppSettingsScreen(navController: NavController) { var downloadJob by remember { mutableStateOf(null) } var downloadError by remember { mutableStateOf(null) } var showUpdateAvailableDialog by remember { mutableStateOf(false) } + var showVersionMenu by remember { mutableStateOf(false) } var notificationEnabled by remember { mutableStateOf(true) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } @@ -433,39 +442,70 @@ fun AppSettingsScreen(navController: NavController) { ), ) { Column { - ListItem( - headlineContent = { - Text( - stringResource(R.string.app_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - BuildConfig.VERSION_NAME, - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - if (hasUpdate) { - Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } + Box { + ListItem( + headlineContent = { + Text( + stringResource(R.string.app_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasUpdate) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .combinedClickable( + onClick = {}, + onLongClick = { showVersionMenu = true }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + DropdownMenu( + expanded = showVersionMenu, + onDismissRequest = { showVersionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + ) + }, + onClick = { + clipboardText = BuildConfig.VERSION_NAME + Toast.makeText( + context, + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + showVersionMenu = false + }, + ) } - }, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) + } + } ListItem( headlineContent = { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt index c23400c..ff44ad8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -5,8 +5,11 @@ import android.content.Context import android.content.Intent import android.provider.DocumentsContract import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Info @@ -25,6 +29,8 @@ import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -41,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -52,11 +59,12 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.ktx.clipboardText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CoreSettingsScreen(navController: NavController) { OverrideTopBar { @@ -77,6 +85,7 @@ fun CoreSettingsScreen(navController: NavController) { val scope = rememberCoroutineScope() var dataSize by remember { mutableStateOf("") } val version = remember { Libbox.version() } + var showVersionMenu by remember { mutableStateOf(false) } var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } // Calculate data size on launch @@ -114,34 +123,66 @@ fun CoreSettingsScreen(navController: NavController) { ) { Column { // Version Info - ListItem( - headlineContent = { - Text( - stringResource(R.string.core_version_title), - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Text( - version, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), - ) + Box { + ListItem( + headlineContent = { + Text( + stringResource(R.string.core_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + version, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .combinedClickable( + onClick = {}, + onLongClick = { showVersionMenu = true }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + DropdownMenu( + expanded = showVersionMenu, + onDismissRequest = { showVersionMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + ) + }, + onClick = { + clipboardText = version + Toast.makeText( + context, + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + showVersionMenu = false + }, + ) + } + } + } // Data Size ListItem( From c8491c9212e0a20a5a970c7469f8ca29cf3a4da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 19 Apr 2026 23:57:35 +0800 Subject: [PATCH 32/45] Fix LocalResolver blocking on missing default network Return `missing default interface` immediately instead of suspending inside `DefaultNetworkMonitor.require()` when no default network is available. --- app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt index 26f0254..e382996 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt @@ -23,8 +23,8 @@ object LocalResolver : LocalDNSTransport { @RequiresApi(Build.VERSION_CODES.Q) override fun exchange(ctx: ExchangeContext, message: ByteArray) { + val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface") return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() suspendCoroutine { continuation -> val signal = CancellationSignal() ctx.onCancel(signal::cancel) @@ -63,8 +63,8 @@ object LocalResolver : LocalDNSTransport { } override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + val defaultNetwork = DefaultNetworkMonitor.defaultNetwork ?: error("missing default interface") return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { suspendCoroutine { continuation -> val signal = CancellationSignal() From f929e52e411b2a28d60f0192e640cd6161e32c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 07:59:55 +0800 Subject: [PATCH 33/45] Restrict beta settings to beta versions --- .../screen/settings/CoreSettingsScreen.kt | 99 ++++++++++--------- app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values-zh-rTW/strings.xml | 3 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 57 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt index ff44ad8..618aa75 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -222,57 +222,58 @@ fun CoreSettingsScreen(navController: NavController) { } } - // Options Section - Spacer(modifier = Modifier.height(16.dp)) + if (version.contains("-")) { + Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.options), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), - ) - - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - ListItem( - headlineContent = { - Text( - stringResource(R.string.disable_deprecated_warnings), - style = MaterialTheme.typography.bodyLarge, - ) - }, - leadingContent = { - Icon( - imageVector = Icons.Outlined.WarningAmber, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - trailingContent = { - Switch( - checked = disableDeprecatedWarnings, - onCheckedChange = { checked -> - disableDeprecatedWarnings = checked - scope.launch(Dispatchers.IO) { - Settings.disableDeprecatedWarnings = checked - } - }, - ) - }, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent, - ), + Text( + text = stringResource(R.string.beta_settings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.disable_deprecated_warnings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = disableDeprecatedWarnings, + onCheckedChange = { checked -> + disableDeprecatedWarnings = checked + scope.launch(Dispatchers.IO) { + Settings.disableDeprecatedWarnings = checked + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } } // Working Directory Section diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 4989819..db997c6 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -198,6 +198,7 @@ کد منبع حامی مالی پوشه کاری + تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ اعلان‌ها فعال‌کردن اعلان diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3e9fca6..274a39d 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -198,6 +198,7 @@ Исходный код Поддержать Рабочая директория + Бета-настройки Отключить предупреждения об устаревании Уведомления Включить уведомления diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7ca82f6..2f7df1a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -198,6 +198,7 @@ 源代码 赞助 工作目录 + Beta 版设置 禁用弃用警告 缓存大小 清除缓存 @@ -271,7 +272,7 @@ 是否启用从 **GitHub** 自动检查更新? 更新轨道 稳定版 - 测试版 + Beta 版 当前轨道尚不支持检查更新 查看发布 下载中… diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 554892d..e0d79aa 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -198,6 +198,7 @@ 原始碼 贊助 工作目錄 + Beta 版設定 停用過時警告 快取大小 清除快取 @@ -271,7 +272,7 @@ 是否啟用從 **GitHub** 自動檢查更新? 更新通道 穩定版 - 測試版 + Beta 版 目前通道尚不支援檢查更新 查看發布 下載中… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d020cb..064ccb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,6 +198,7 @@ Source Code Sponsor Working Directory + Beta Settings Disable Deprecated Warnings Cache Size Clear Cache From 67a19777ce5787843f40fc8bc8b83fe6a1274fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 20 Apr 2026 08:15:59 +0800 Subject: [PATCH 34/45] Bump version 1.13.9 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 001047c..8a42b5a 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=654 -VERSION_NAME=1.13.8 +VERSION_CODE=658 +VERSION_NAME=1.13.9 GO_VERSION=go1.25.9 From a3f4ca31d122756f36e8f41e5d3d8d676ba3e4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Apr 2026 17:33:49 +0800 Subject: [PATCH 35/45] Bump version 1.13.10 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 8a42b5a..21c600e 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=658 -VERSION_NAME=1.13.9 +VERSION_CODE=660 +VERSION_NAME=1.13.10 GO_VERSION=go1.25.9 From 3b3883ef2c7fa5cb4d7bc7fe9846a7661f636e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 07:23:36 +0800 Subject: [PATCH 36/45] Bump version 1.13.11 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 21c600e..0d0ef43 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=660 -VERSION_NAME=1.13.10 +VERSION_CODE=662 +VERSION_NAME=1.13.11 GO_VERSION=go1.25.9 From b3b09454c06bd04baad363f119c6a65ed7b6e301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 11:35:49 +0800 Subject: [PATCH 37/45] Tools View & Crash Report & OOM Report --- .../java/io/nekohasekai/sfa/Application.kt | 56 ++- .../java/io/nekohasekai/sfa/bg/BoxService.kt | 7 + .../nekohasekai/sfa/bg/CrashReportManager.kt | 251 ++++++++++ .../io/nekohasekai/sfa/bg/OOMReportManager.kt | 165 +++++++ .../nekohasekai/sfa/compose/MainActivity.kt | 165 ++++++- .../base/ApplyServiceChangeNotifier.kt | 16 + .../nekohasekai/sfa/compose/base/UiEvent.kt | 7 +- .../navigation/NavigationDestinations.kt | 8 + .../sfa/compose/navigation/SFANavigation.kt | 124 ++++- .../PrivilegeSettingsManageScreen.kt | 11 +- .../profileoverride/PerAppProxyScreen.kt | 19 +- .../screen/settings/AppSettingsScreen.kt | 12 +- .../settings/PrivilegeSettingsScreen.kt | 15 +- .../screen/settings/ProfileOverrideScreen.kt | 47 +- .../screen/settings/ServiceSettingsScreen.kt | 14 +- .../screen/tools/CrashReportDetailScreen.kt | 459 ++++++++++++++++++ .../screen/tools/CrashReportListScreen.kt | 263 ++++++++++ .../screen/tools/OOMReportDetailScreen.kt | 451 +++++++++++++++++ .../screen/tools/OOMReportListScreen.kt | 416 ++++++++++++++++ .../sfa/compose/screen/tools/ToolsScreen.kt | 127 +++++ .../nekohasekai/sfa/constant/SettingsKey.kt | 5 + .../io/nekohasekai/sfa/database/Settings.kt | 4 + app/src/main/res/values-fa/strings.xml | 31 ++ app/src/main/res/values-ru-rRU/strings.xml | 31 ++ app/src/main/res/values-zh-rCN/strings.xml | 36 ++ app/src/main/res/values-zh-rTW/strings.xml | 31 ++ app/src/main/res/values/strings.xml | 36 ++ 27 files changed, 2743 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 02b2467..1250f80 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -10,12 +10,14 @@ import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.PowerManager import androidx.core.content.getSystemService -import go.Seq import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.sfa.bg.AppChangeReceiver +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.constant.Bugs +import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookStatusClient @@ -43,9 +45,20 @@ class Application : Application() { HookStatusClient.register(this) PrivilegeSettingsClient.register(this) + val baseDir = filesDir + baseDir.mkdirs() + val workingDir = getExternalFilesDir(null) + val tempDir = cacheDir + tempDir.mkdirs() + if (workingDir != null) { + workingDir.mkdirs() + CrashReportManager.install(workingDir, baseDir) + OOMReportManager.install(workingDir) + } + @Suppress("OPT_IN_USAGE") GlobalScope.launch(Dispatchers.IO) { - initialize() + initialize(baseDir, workingDir, tempDir) UpdateProfileWork.reconfigureUpdater() HookModuleUpdateNotifier.sync(this@Application) } @@ -62,24 +75,33 @@ class Application : Application() { } } - private fun initialize() { + private fun initialize(baseDir: File, workingDir: File?, tempDir: File) { + val actualWorkingDir = workingDir ?: return + setupLibbox(baseDir, actualWorkingDir, tempDir) + } + + fun reloadSetupOptions() { val baseDir = filesDir - baseDir.mkdirs() val workingDir = getExternalFilesDir(null) ?: return - workingDir.mkdirs() val tempDir = cacheDir - tempDir.mkdirs() - Libbox.setup( - SetupOptions().also { - it.basePath = baseDir.path - it.workingPath = workingDir.path - it.tempPath = tempDir.path - it.fixAndroidStack = Bugs.fixAndroidStack - it.logMaxLines = 3000 - it.debug = BuildConfig.DEBUG - }, - ) - Libbox.redirectStderr(File(workingDir, "stderr.log").path) + Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) { + Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also { + it.basePath = baseDir.path + it.workingPath = workingDir.path + it.tempPath = tempDir.path + it.fixAndroidStack = Bugs.fixAndroidStack + it.logMaxLines = 3000 + it.debug = BuildConfig.DEBUG + it.crashReportSource = "Application" + it.oomKillerEnabled = Settings.oomKillerEnabled + it.oomKillerDisabled = Settings.oomKillerDisabled + it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L } companion object { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index a354866..da211c8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -417,6 +417,13 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } + override fun triggerNativeCrash() { + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + } + override fun writeDebugMessage(message: String?) { Log.d("sing-box", message!!) } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt new file mode 100644 index 0000000..cb2a27b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -0,0 +1,251 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class CrashReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class CrashReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + GO_LOG, + JVM_LOG, + CONFIG, + } +} + +object CrashReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val GO_LOG_FILE_NAME = "go.log" + private const val JVM_LOG_FILE_NAME = "jvm.log" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val READ_MARKER_FILE_NAME = ".read" + private const val CRASH_REPORTS_DIR_NAME = "crash_reports" + private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log" + private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + private lateinit var baseDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File, baseDir: File) { + this.workingDir = workingDir + this.baseDir = baseDir + archivePendingJvmCrashReport() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + writePendingJvmCrashReport(thread, throwable) + previous?.uncaughtException(thread, throwable) + } + } + + private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) { + try { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString()) + val metadata = JSONObject().apply { + put("source", "Application") + put("crashedAt", formatTimestampISO8601(Date())) + put("exceptionName", throwable.javaClass.name) + put("exceptionReason", throwable.message ?: "") + put("processName", Application.application.packageName) + put("appVersion", BuildConfig.VERSION_CODE.toString()) + put("appMarketingVersion", BuildConfig.VERSION_NAME) + runCatching { + put("coreVersion", Libbox.version()) + put("goVersion", Libbox.goVersion()) + } + } + File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString()) + } catch (_: Throwable) { + } + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanCrashReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun archivePendingJvmCrashReport() { + val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME) + val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME) + val configFile = File(baseDir, CONFIG_FILE_NAME) + if (!crashFile.exists()) return + val content = crashFile.readText().trim() + if (content.isEmpty()) { + crashFile.delete() + metadataFile.delete() + configFile.delete() + return + } + val crashDate = Date(crashFile.lastModified()) + val reportDir = nextAvailableReportDir(crashDate) + reportDir.mkdirs() + crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true) + crashFile.delete() + if (metadataFile.exists()) { + metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true) + metadataFile.delete() + } + if (configFile.exists()) { + val configContent = runCatching { configFile.readText() }.getOrNull()?.trim() + if (!configContent.isNullOrEmpty()) { + configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true) + } + configFile.delete() + } + } + + private fun scanCrashReports(): List { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + if (!crashReportsDir.isDirectory) return emptyList() + val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + CrashReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: CrashReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val goLogFile = File(report.directory, GO_LOG_FILE_NAME) + if (goLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile)) + } + val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME) + if (jvmLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile)) + } + return files + } + + fun loadFileContent(file: CrashReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == CrashReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: CrashReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun nextAvailableReportDir(date: Date): File { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + val baseName = timestampFormat.format(date) + var index = 0 + while (true) { + val suffix = if (index == 0) "" else "-$index" + val dir = File(crashReportsDir, baseName + suffix) + if (!dir.exists()) return dir + index++ + } + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } + + private fun formatTimestampISO8601(date: Date): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return format.format(date) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt new file mode 100644 index 0000000..183b19e --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -0,0 +1,165 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class OOMReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class OOMReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + CONFIG, + PROFILE, + } +} + +object OOMReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val CMDLINE_FILE_NAME = "cmdline" + private const val READ_MARKER_FILE_NAME = ".read" + private const val OOM_REPORTS_DIR_NAME = "oom_reports" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File) { + this.workingDir = workingDir + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun scanReports(): List { + val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME) + if (!reportsDir.isDirectory) return emptyList() + val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + OOMReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: OOMReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile)) + } + report.directory.listFiles()?.filter { file -> + file.isFile && + file.name != METADATA_FILE_NAME && + file.name != CONFIG_FILE_NAME && + file.name != CMDLINE_FILE_NAME && + file.name != READ_MARKER_FILE_NAME + }?.sortedBy { it.name }?.forEach { file -> + files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file)) + } + return files + } + + fun loadFileContent(file: OOMReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == OOMReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: OOMReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 56e8639..059c64d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -87,6 +87,9 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compat.WindowSizeClassCompat @@ -126,6 +129,7 @@ import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -327,6 +331,89 @@ class MainActivity : // Snackbar state val snackbarHostState = remember { SnackbarHostState() } + // Error dialog state for UiEvent.ShowError + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var pendingApplyServiceChangeMode by remember { mutableStateOf(null) } + var activeApplyServiceChangeMode by remember { mutableStateOf(null) } + var applyServiceChangeJob by remember { mutableStateOf(null) } + + fun mergeApplyServiceChangeMode( + current: UiEvent.ApplyServiceChange.Mode?, + incoming: UiEvent.ApplyServiceChange.Mode, + ): UiEvent.ApplyServiceChange.Mode = when { + current == UiEvent.ApplyServiceChange.Mode.Restart || + incoming == UiEvent.ApplyServiceChange.Mode.Restart -> { + UiEvent.ApplyServiceChange.Mode.Restart + } + + else -> incoming + } + + fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) { + if (currentServiceStatus != Status.Started) { + return + } + + pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode) + + val activeMode = activeApplyServiceChangeMode + if (activeMode != null && + mergeApplyServiceChangeMode(activeMode, mode) != activeMode + ) { + snackbarHostState.currentSnackbarData?.dismiss() + } + + if (applyServiceChangeJob?.isActive == true) { + return + } + + applyServiceChangeJob = + scope.launch { + while (true) { + val modeToShow = pendingApplyServiceChangeMode ?: break + pendingApplyServiceChangeMode = null + activeApplyServiceChangeMode = modeToShow + val (message, actionLabel) = + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + getString(R.string.service_reload_required) to + getString(R.string.action_reload) + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + getString(R.string.service_restart_required) to + getString(R.string.action_restart) + } + } + val result = + snackbarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = androidx.compose.material3.SnackbarDuration.Short, + ) + activeApplyServiceChangeMode = null + if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { + try { + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + withContext(Dispatchers.IO) { + Libbox.newStandaloneCommandClient().serviceReload() + } + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + restartServiceForApplyChange() + } + } + } catch (e: Exception) { + errorMessage = e.message ?: e.toString() + showErrorDialog = true + } + } + } + } + } // Groups Sheet state var showGroupsSheet by remember { mutableStateOf(false) } @@ -335,8 +422,6 @@ class MainActivity : var showConnectionsSheet by remember { mutableStateOf(false) } // Error dialog state for UiEvent.ShowError - var showErrorDialog by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf("") } val pendingIntentError = pendingIntentErrorMessage LaunchedEffect(pendingIntentError) { if (pendingIntentError != null) { @@ -616,11 +701,13 @@ class MainActivity : val dashboardUiState by dashboardViewModel.uiState.collectAsState() val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true + val isToolsSubScreen = currentRoute?.startsWith("tools/") == true val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true val isProfileRoute = currentRoute?.startsWith("profile/") == true val currentRootRoute = when { isSettingsSubScreen -> Screen.Settings.route + isToolsSubScreen -> Screen.Tools.route currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route isProfileRoute -> Screen.Dashboard.route @@ -630,7 +717,7 @@ class MainActivity : val isGroupsRoute = currentRootRoute == Screen.Groups.route val isLogRoute = currentRootRoute == Screen.Log.route - val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute + val isSubScreen = isSettingsSubScreen || isToolsSubScreen || isConnectionsDetail || isProfileRoute // Get LogViewModel instance if we're on the Log screen val logViewModel: LogViewModel? = if (isLogRoute) { @@ -660,6 +747,14 @@ class MainActivity : null } + val isToolsRoute = currentRootRoute == Screen.Tools.route + val tailscaleStatusViewModel: TailscaleStatusViewModel? = + if (isToolsRoute) { + viewModel() + } else { + null + } + val showGroupsInNav = dashboardUiState.hasGroups val showConnectionsInNav = currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting @@ -674,6 +769,7 @@ class MainActivity : add(Screen.Connections) } add(Screen.Log) + add(Screen.Tools) add(Screen.Settings) } @@ -681,6 +777,7 @@ class MainActivity : buildSet { add(Screen.Dashboard.route) add(Screen.Log.route) + add(Screen.Tools.route) add(Screen.Settings.route) if (useNavigationRail && showGroupsInNav) { add(Screen.Groups.route) @@ -739,24 +836,7 @@ class MainActivity : } } - is UiEvent.RestartToTakeEffect -> { - if (currentServiceStatus == Status.Started) { - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - val result = - snackbarHostState.showSnackbar( - message = "Restart to take effect", - actionLabel = "Restart", - duration = androidx.compose.material3.SnackbarDuration.Short, - ) - if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { - withContext(Dispatchers.IO) { - Libbox.newStandaloneCommandClient().serviceReload() - } - } - } - } - } + is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode) } } } @@ -919,6 +999,17 @@ class MainActivity : } } + val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState() + val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState() + val toolsUnreadCount = crashReportUnreadCount + oomReportUnreadCount + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + CrashReportManager.refresh() + OOMReportManager.refresh() + } + } + CompositionLocalProvider(LocalTopBarController provides topBarController) { if (useNavigationRail) { Row(modifier = Modifier.fillMaxSize()) { @@ -936,6 +1027,10 @@ class MainActivity : BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { Icon(screen.icon, contentDescription = null) } + } else if (screen == Screen.Tools && toolsUnreadCount > 0) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) { + Icon(screen.icon, contentDescription = null) + } } else { Icon(screen.icon, contentDescription = null) } @@ -980,6 +1075,10 @@ class MainActivity : BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { Icon(screen.icon, contentDescription = null) } + } else if (screen == Screen.Tools && toolsUnreadCount > 0) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) { + Icon(screen.icon, contentDescription = null) + } } else { Icon(screen.icon, contentDescription = null) } @@ -1192,6 +1291,30 @@ class MainActivity : showBackgroundLocationDialog = true } + private suspend fun restartServiceForApplyChange() { + if (currentServiceStatus != Status.Started) { + return + } + + BoxService.stop() + while (true) { + when (currentServiceStatus) { + Status.Stopped -> { + startService() + return + } + + Status.Starting -> { + return + } + + Status.Started, Status.Stopping -> { + delay(100L) + } + } + } + } + override fun onDestroy() { connection.disconnect() super.onDestroy() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt new file mode 100644 index 0000000..7f31617 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt @@ -0,0 +1,16 @@ +package io.nekohasekai.sfa.compose.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.nekohasekai.sfa.constant.Status + +@Composable +fun rememberApplyServiceChangeNotifier( + serviceStatus: Status, +): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) { + { mode -> + if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 6b7467a..70b1f3c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -19,7 +19,12 @@ sealed class UiEvent { object RequestReconnectService : UiEvent() - object RestartToTakeEffect : UiEvent() + data class ApplyServiceChange(val mode: Mode) : UiEvent() { + enum class Mode { + Reload, + Restart, + } + } } /** diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 27456b9..9ae23cc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Terminal import androidx.compose.ui.graphics.vector.ImageVector import io.nekohasekai.sfa.R @@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I icon = Icons.Default.SwapVert, ) + object Tools : Screen( + route = "tools", + titleRes = R.string.title_tools, + icon = Icons.Default.Terminal, + ) + object Settings : Screen( route = "settings", titleRes = R.string.title_settings, @@ -46,5 +53,6 @@ val bottomNavigationScreens = listOf( Screen.Dashboard, Screen.Log, + Screen.Tools, Screen.Settings, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index d6e5f22..e52ee71 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -33,6 +33,15 @@ import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen import io.nekohasekai.sfa.constant.Status private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { @@ -210,6 +219,111 @@ fun SFANavHost( } } + composable(Screen.Tools.route) { + ToolsScreen(navController = navController) + } + + // Tools subscreens with slide animations + composable( + route = "tools/crash_report", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + CrashReportListScreen(navController = navController) + } + + composable( + route = "tools/crash_report/{reportId}", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + CrashReportDetailScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/crash_report/{reportId}/metadata", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + CrashReportMetadataScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/crash_report/{reportId}/file/{fileKind}", + arguments = listOf( + navArgument("reportId") { type = NavType.StringType }, + navArgument("fileKind") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable + CrashReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind) + } + + composable( + route = "tools/oom_report", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + OOMReportListScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/oom_report/{reportId}", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + OOMReportDetailScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/oom_report/{reportId}/metadata", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + OOMReportMetadataScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/oom_report/{reportId}/file/{fileKind}", + arguments = listOf( + navArgument("reportId") { type = NavType.StringType }, + navArgument("fileKind") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable + OOMReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind) + } + composable(Screen.Settings.route) { SettingsScreen(navController = navController) } @@ -222,7 +336,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - AppSettingsScreen(navController = navController) + AppSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -252,7 +366,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ServiceSettingsScreen(navController = navController) + ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -262,7 +376,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ProfileOverrideScreen(navController = navController) + ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -272,7 +386,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PerAppProxyScreen(onBack = { navController.navigateUp() }) + PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } composable( @@ -292,7 +406,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) + PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) } composable( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt index 8734b43..46b170f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.utils.PrivilegeSettingsClient @@ -95,10 +98,14 @@ private enum class RiskCategory { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { +fun PrivilegeSettingsManageScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortReverse by remember { mutableStateOf(false) } @@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { } if (failure != null) { syncErrorMessage = failure.message ?: failure.toString() + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt index 74b8986..3c553b2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.vendor.PackageQueryManager @@ -106,10 +109,14 @@ private sealed class ScanResult { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PerAppProxyScreen(onBack: () -> Unit) { +fun PerAppProxyScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } var sortMode by remember { mutableStateOf(SortMode.NAME) } @@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { fun saveSelectedApplications(newUids: Set) { coroutineScope.launch { - Settings.perAppProxyList = buildPackageList(newUids) + withContext(Dispatchers.IO) { + Settings.perAppProxyList = buildPackageList(newUids) + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } @@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { onModeChange = { mode -> proxyMode = mode coroutineScope.launch { - Settings.perAppProxyMode = mode + withContext(Dispatchers.IO) { + Settings.perAppProxyMode = mode + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } }, onSortModeChange = { mode -> diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 9c94f63..028d021 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -87,8 +87,11 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.update.UpdateCheckException @@ -109,7 +112,10 @@ import android.provider.Settings as AndroidSettings @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun AppSettingsScreen(navController: NavController) { +fun AppSettingsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_app_settings)) }, @@ -155,6 +161,7 @@ fun AppSettingsScreen(navController: NavController) { var notificationEnabled by remember { mutableStateOf(true) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var showDisableNotificationDialog by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var showLanguageDialog by remember { mutableStateOf(false) } val availableLocales = remember { getSupportedLocales(context) } @@ -679,6 +686,9 @@ fun AppSettingsScreen(navController: NavController) { dynamicNotification = checked scope.launch(Dispatchers.IO) { Settings.dynamicNotification = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt index dbcf6bc..0187c20 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -62,9 +62,9 @@ import androidx.core.content.FileProvider import androidx.navigation.NavController import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings @@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status val context = LocalContext.current val scope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val systemHookStatus by HookStatusClient.status.collectAsState() var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } @@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (checked && serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 22370e8..6d1688b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers @@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileOverrideScreen(navController: NavController) { +fun ProfileOverrideScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.profile_override)) }, @@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) { var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var isScanning by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) - fun scanAndSaveManagedList() { + fun scanAndSaveManagedList(shouldNotify: Boolean = false) { isScanning = true scope.launch { val chinaApps = PerAppProxyScanner.scanAllChinaApps() @@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyManagedList = chinaApps } isScanning = false + if (shouldNotify) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } @@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } @@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) { withContext(Dispatchers.IO) { Settings.autoRedirect = true } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } else { Toast.makeText( context, @@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) { autoRedirect = false scope.launch(Dispatchers.IO) { Settings.autoRedirect = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = checked scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = checked + if (!checked || !managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (checked && managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } } }, @@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = true } - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } else { managedModeEnabled = false scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = true scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = true + if (!managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } }, ) { @@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } else { showRootDialog = false @@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = false } } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( @@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index b6038d9..2171997 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -57,15 +57,23 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.launchCustomTab import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { +fun ServiceSettingsScreen( + navController: NavController, + serviceConnection: ServiceConnection? = null, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.service)) }, @@ -84,6 +92,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi val scope = rememberCoroutineScope() var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } var allowBypass by remember { mutableStateOf(Settings.allowBypass) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val requestBatteryOptimizationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), @@ -255,6 +264,9 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi allowBypass = checked scope.launch(Dispatchers.IO) { Settings.allowBypass = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt new file mode 100644 index 0000000..bd7d712 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -0,0 +1,459 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReport +import io.nekohasekai.sfa.bg.CrashReportFile +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportDetailScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = CrashReportManager.availableFiles(report) + } + CrashReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && CrashReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + CrashReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + CrashReportFile.Kind.METADATA -> Icons.Default.DataObject + CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal + CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport + CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + if (file.kind == CrashReportFile.Kind.METADATA) { + navController.navigate("tools/crash_report/$reportId/metadata") + } else { + navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}") + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportMetadataScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + ) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + key, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(value) + }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } + } + } +} + +private fun loadMetadataEntries(report: CrashReport): List> { + val metadataFile = CrashReportManager.availableFiles(report) + .find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull() + val displayName = when (kind) { + CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log) + CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log) + CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration) + else -> fileKind + } + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = CrashReportManager.availableFiles(report).find { it.kind == kind } + content = if (file != null) CrashReportManager.loadFileContent(file) else "" + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt new file mode 100644 index 0000000..aa238a0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt @@ -0,0 +1,263 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.FlashOn +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportListScreen(navController: NavController) { + val reports by CrashReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + var crashTriggerExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + CrashReportManager.refresh() + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.crash_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (reports.isNotEmpty() || BuildConfig.DEBUG) { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { + menuExpanded = false + crashTriggerExpanded = false + }, + ) { + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("Crash Trigger") }, + leadingIcon = { + Icon( + Icons.Outlined.FlashOn, + contentDescription = null, + ) + }, + onClick = { crashTriggerExpanded = !crashTriggerExpanded }, + ) + if (crashTriggerExpanded) { + DropdownMenuItem( + text = { + Text( + "Go Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Libbox.triggerGoPanic() + }, + ) + DropdownMenuItem( + text = { + Text( + "Native Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + }, + ) + } + } + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { + CrashReportManager.deleteAll() + } + }, + ) + } + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/crash_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.crash_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt new file mode 100644 index 0000000..f7936ac --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -0,0 +1,451 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReport +import io.nekohasekai.sfa.bg.OOMReportFile +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportDetailScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = OOMReportManager.availableFiles(report) + } + OOMReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && OOMReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + OOMReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + OOMReportFile.Kind.METADATA -> Icons.Default.DataObject + OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings + OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .then( + if (file.kind != OOMReportFile.Kind.PROFILE) { + Modifier.clickable { + if (file.kind == OOMReportFile.Kind.METADATA) { + navController.navigate("tools/oom_report/$reportId/metadata") + } else { + navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}") + } + } + } else { + Modifier + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportMetadataScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadOOMMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text(key, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { Text(value) }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } +} + +private fun loadOOMMetadataEntries(report: OOMReport): List> { + val metadataFile = OOMReportManager.availableFiles(report) + .find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(fileKind) } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull() + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = OOMReportManager.availableFiles(report).find { it.kind == kind } + if (file != null) { + displayName = file.displayName + content = OOMReportManager.loadFileContent(file) + } + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt new file mode 100644 index 0000000..94baff3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt @@ -0,0 +1,416 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Date + +private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportListScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { + val reports by OOMReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf(null) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) + + var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) } + var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) } + var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) } + var showMemoryLimitDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + OOMReportManager.refresh() + val storedLimit = Settings.oomMemoryLimitMB + if (!memoryLimitOptions.contains(storedLimit)) { + oomMemoryLimitMB = memoryLimitOptions.first() + Settings.oomMemoryLimitMB = oomMemoryLimitMB + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.oom_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.oom_report_fetch)) }, + leadingIcon = { + Icon(Icons.Outlined.Memory, contentDescription = null) + }, + onClick = { + menuExpanded = false + if (serviceStatus != Status.Started) { + errorMessage = + Application.application.getString(R.string.service_not_started) + } else { + scope.launch { + val failure = + withContext(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().triggerOOMReport() + }.exceptionOrNull() + } + if (failure != null) { + errorMessage = failure.message ?: failure.toString() + } else { + delay(1000) + withContext(Dispatchers.IO) { + OOMReportManager.refresh() + } + } + } + } + }, + ) + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { OOMReportManager.deleteAll() } + }, + ) + } + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Reports section + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/oom_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.oom_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + // Settings section + Text( + stringResource(R.string.title_settings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_enable_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_enable_memory_limit_description)) + }, + trailingContent = { + Switch( + checked = oomKillerEnabled, + onCheckedChange = { checked -> + oomKillerEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerEnabled = checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + AnimatedVisibility(visible = oomKillerEnabled) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L)) + }, + modifier = Modifier.clickable { showMemoryLimitDialog = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_kill_connections), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_kill_connections_description)) + }, + trailingContent = { + Switch( + checked = oomKillerKillConnections, + onCheckedChange = { checked -> + oomKillerKillConnections = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerDisabled = !checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } + } + + errorMessage?.let { message -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + confirmButton = { + TextButton(onClick = { errorMessage = null }) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { Text(message) }, + ) + } + + if (showMemoryLimitDialog) { + AlertDialog( + onDismissRequest = { showMemoryLimitDialog = false }, + title = { Text(stringResource(R.string.oom_report_memory_limit)) }, + text = { + Column { + memoryLimitOptions.forEach { value -> + ListItem( + headlineContent = { + Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L)) + }, + leadingContent = { + RadioButton( + selected = value == oomMemoryLimitMB, + onClick = null, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + oomMemoryLimitMB = value + showMemoryLimitDialog = false + scope.launch(Dispatchers.IO) { + Settings.oomMemoryLimitMB = value + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt new file mode 100644 index 0000000..fc3f081 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -0,0 +1,127 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ToolsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_tools)) }, + ) + } + + val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() + val oomUnreadCount by OOMReportManager.unreadCount.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.title_debug), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.crash_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (crashUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$crashUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/crash_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Memory, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (oomUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$oomUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/oom_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 2f41fea..8454278 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -31,6 +31,11 @@ object SettingsKey { const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" + // OOM killer + const val OOM_KILLER_ENABLED = "oom_killer_enabled" + const val OOM_KILLER_DISABLED = "oom_killer_disabled" + const val OOM_MEMORY_LIMIT_MB = "oom_memory_limit_mb" + // dashboard const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 64f417f..721ac01 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -106,6 +106,10 @@ object Settings { ) { false } var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } + var oomKillerEnabled by dataStore.boolean(SettingsKey.OOM_KILLER_ENABLED) { false } + var oomKillerDisabled by dataStore.boolean(SettingsKey.OOM_KILLER_DISABLED) { true } + var oomMemoryLimitMB by dataStore.int(SettingsKey.OOM_MEMORY_LIMIT_MB) { 50 } + var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index db997c6..d97cf2e 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -406,6 +406,37 @@ جمع کردن جستجو جستجوی لاگ‌ها + + ابزارها + + + خالی + گزارش‌ها + فایل‌ها + حذف همه + حذف + اشتراک‌گذاری + اشتراک‌گذاری با پیکربندی + فراداده + پیکربندی + محلی + سرویس شروع نشده است + + + گزارش خرابی + Go Crash Log + JVM Crash Log + + + گزارش کمبود حافظه + هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید. + دریافت گزارش حافظه + فعال‌سازی محدودیت حافظه + یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند. + محدودیت حافظه + قطع اتصالات + هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید. + بهبود دسترسی ویژه برای sing-box diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 274a39d..15ec972 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -412,6 +412,37 @@ Свернуть поиск Поиск в логе + + Инструменты + + + Пусто + Отчёты + Файлы + Удалить все + Удалить + Поделиться + Поделиться с конфигурацией + Метаданные + Конфигурация + Локальный + Служба не запущена + + + Отчёт о сбое + Go Crash Log + JVM Crash Log + + + Отчёт о нехватке памяти + При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта. + Получить отчёт о памяти + Включить ограничение памяти + Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения. + Ограничение памяти + Завершить соединения + Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса. + Привилегированное расширение для sing-box diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f7df1a..ff99bd0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,6 +23,8 @@ 操作 启动 取消选择 + 重载 + 重启 展开 收起 全部展开 @@ -421,6 +423,40 @@ 折叠搜索 搜索日志 + + 工具 + + + + 报告 + 文件 + 全部删除 + 删除 + 分享 + 附带配置分享 + 元数据 + 配置 + 本地 + 服务未启动 + 需要重载服务以应用更改 + 需要重启服务以应用更改 + + + 崩溃报告 + Go Crash Log + JVM Crash Log + 当遇到崩溃时,您将会收到报告。 + + + 内存不足报告 + 启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。 + 获取内存报告 + 启用内存限制 + 为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。 + 内存限制 + 终止连接 + 当服务内存超出限制时,终止所有连接以释放内存。 + sing-box 的特权增强 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e0d79aa..7ebd5a1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -424,6 +424,37 @@ 收合搜尋 搜尋日誌 + + 工具 + + + + 報告 + 檔案 + 全部刪除 + 刪除 + 分享 + 附帶配置分享 + 元數據 + 配置 + 本地 + 服務未啟動 + + + 當機報告 + Go Crash Log + JVM Crash Log + + + 記憶體不足報告 + 啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。 + 取得記憶體報告 + 啟用記憶體限制 + 為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。 + 記憶體限制 + 終止連線 + 當服務記憶體超出限制時,終止所有連線以釋放記憶體。 + sing-box 的特權強化 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 064ccb7..633755b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Action Start Deselect + Reload + Restart Expand Collapse Expand All @@ -424,6 +426,40 @@ Collapse search Search logs + + Tools + + + Empty + Reports + Files + Delete All + Delete + Share + Share With Configuration + Metadata + Configuration + Local + Service not started + Reload service to apply changes + Restart service to apply changes + + + Crash Report + Go Crash Log + JVM Crash Log + You will receive a report when a crash occurs. + + + OOM Report + When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection. + Fetch Memory Report + Enable Memory Limit + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + Memory Limit + Kill Connections + Kill all connections to free memory when the service memory exceeds the limit. + Privileged Enhancement for sing-box From 696976c4a04ca1ea001f98316755dda754e6bb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 5 Mar 2026 00:16:03 +0800 Subject: [PATCH 38/45] Add support for MAC and hostname rule items --- .../sfa/bg/INeighborTableCallback.aidl | 7 + .../io/nekohasekai/sfa/bg/IRootService.aidl | 5 + .../io/nekohasekai/sfa/bg/NeighborEntry.aidl | 3 + .../io/nekohasekai/sfa/bg/NeighborEntry.java | 49 +++++ .../sfa/bg/PlatformInterfaceWrapper.kt | 50 +++++ .../java/io/nekohasekai/sfa/bg/RootClient.kt | 17 ++ .../java/io/nekohasekai/sfa/bg/RootServer.kt | 188 ++++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl create mode 100644 app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl create mode 100644 app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl new file mode 100644 index 0000000..a2ed3cf --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.bg; + +import io.nekohasekai.sfa.bg.ParceledListSlice; + +interface INeighborTableCallback { + oneway void onNeighborTableUpdated(in ParceledListSlice entries); +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl index fc58161..382c192 100644 --- a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -1,6 +1,7 @@ package io.nekohasekai.sfa.bg; import android.os.ParcelFileDescriptor; +import io.nekohasekai.sfa.bg.INeighborTableCallback; import io.nekohasekai.sfa.bg.ParceledListSlice; interface IRootService { @@ -11,4 +12,8 @@ interface IRootService { void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; String exportDebugInfo(String outputPath) = 3; + + void registerNeighborTableCallback(in INeighborTableCallback callback) = 4; + + oneway void unregisterNeighborTableCallback(in INeighborTableCallback callback) = 5; } diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl new file mode 100644 index 0000000..8c3cf81 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable NeighborEntry; diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java new file mode 100644 index 0000000..97c97ad --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.bg; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; + +public class NeighborEntry implements Parcelable { + @NonNull public final String address; + @NonNull public final String macAddress; + @NonNull public final String hostname; + + public NeighborEntry( + @NonNull String address, @NonNull String macAddress, @NonNull String hostname) { + this.address = address; + this.macAddress = macAddress; + this.hostname = hostname; + } + + protected NeighborEntry(Parcel in) { + address = in.readString(); + macAddress = in.readString(); + hostname = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(address); + dest.writeString(macAddress); + dest.writeString(hostname); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public NeighborEntry createFromParcel(Parcel in) { + return new NeighborEntry(in); + } + + @Override + public NeighborEntry[] newArray(int size) { + return new NeighborEntry[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 7a0be3c..78b3888 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -11,12 +11,16 @@ import io.nekohasekai.libbox.ConnectionOwner import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LocalDNSTransport +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.WIFIState import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import java.net.Inet6Address import java.net.InetSocketAddress import java.net.InterfaceAddress @@ -24,8 +28,11 @@ import java.net.NetworkInterface import java.security.KeyStore import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import io.nekohasekai.libbox.NeighborEntry as LibboxNeighborEntry import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface +private var neighborCallback: INeighborTableCallback.Stub? = null + interface PlatformInterfaceWrapper : PlatformInterface { override fun usePlatformAutoDetectInterfaceControl(): Boolean = true @@ -172,6 +179,49 @@ interface PlatformInterfaceWrapper : PlatformInterface { return StringArray(certificates.iterator()) } + override fun startNeighborMonitor(listener: NeighborUpdateListener?) { + if (listener == null) return + val callback = object : INeighborTableCallback.Stub() { + override fun onNeighborTableUpdated(entries: ParceledListSlice<*>?) { + if (entries == null) return + @Suppress("UNCHECKED_CAST") + val list = entries.list as List + listener.updateNeighborTable( + NeighborEntryArray( + list.map { entry -> + LibboxNeighborEntry().apply { + address = entry.address + macAddress = entry.macAddress + hostname = entry.hostname + } + }.iterator(), + ), + ) + } + } + neighborCallback = callback + runBlocking(Dispatchers.IO) { + RootClient.registerNeighborTableCallback(callback) + } + } + + override fun registerMyInterface(name: String?) { + } + + override fun closeNeighborMonitor(listener: NeighborUpdateListener?) { + val callback = neighborCallback ?: return + neighborCallback = null + runBlocking(Dispatchers.IO) { + RootClient.unregisterNeighborTableCallback(callback) + } + } + + private class NeighborEntryArray(private val iterator: Iterator) : NeighborEntryIterator { + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): LibboxNeighborEntry = iterator.next() + } + private class InterfaceArray(private val iterator: Iterator) : NetworkInterfaceIterator { override fun hasNext(): Boolean = iterator.hasNext() diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index a7b24b9..3cdc8f3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -133,4 +133,21 @@ object RootClient { throw e.rethrowFromSystemServer() } } + + suspend fun registerNeighborTableCallback(callback: INeighborTableCallback) { + val svc = bindService() + try { + svc.registerNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() + } + } + + suspend fun unregisterNeighborTableCallback(callback: INeighborTableCallback) { + try { + service?.unregisterNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() + } + } } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt index 352d159..13b0bcc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -2,15 +2,36 @@ package io.nekohasekai.sfa.bg import android.content.Intent import android.content.pm.PackageInfo +import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor +import android.os.RemoteCallbackList +import android.util.Log import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborSubscription +import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils import java.io.IOException +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors class RootServer : RootService() { + private val neighborCallbacks = RemoteCallbackList() + private var neighborSubscription: NeighborSubscription? = null + + private val hostnameByMAC = ConcurrentHashMap() + + @Volatile + private var lastNeighborEntries: List>? = null + + private var tetheringCallback: Any? = null + private var tetheringManager: Any? = null + private val binder = object : IRootService.Stub() { override fun destroy() { stopSelf() @@ -31,7 +52,174 @@ class RootServer : RootService() { outputPath!!, BuildConfig.APPLICATION_ID, ) + + override fun registerNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.register(callback) + synchronized(neighborCallbacks) { + if (neighborSubscription == null) { + try { + neighborSubscription = + Libbox.subscribeNeighborTable(object : NeighborUpdateListener { + override fun updateNeighborTable(entries: NeighborEntryIterator?) { + if (entries == null) return + val rawList = mutableListOf>() + while (entries.hasNext()) { + val entry = entries.next() + rawList.add(entry.address to entry.macAddress) + } + lastNeighborEntries = rawList + broadcastEnrichedEntries(rawList) + } + }) + } catch (e: Exception) { + Log.e("RootServer", "subscribeNeighborTable failed", e) + } + startTetheringMonitor() + } + } + } + + override fun unregisterNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.unregister(callback) + synchronized(neighborCallbacks) { + if (neighborCallbacks.registeredCallbackCount == 0) { + neighborSubscription?.close() + neighborSubscription = null + stopTetheringMonitor() + } + } + } + } + + private fun broadcastEnrichedEntries(rawList: List>) { + val list = rawList.map { (address, mac) -> + NeighborEntry(address, mac, hostnameByMAC[mac.uppercase()] ?: "") + } + Log.d("RootServer", "neighborTable updated: ${list.size} entries") + val slice = ParceledListSlice(list) + val count = neighborCallbacks.beginBroadcast() + try { + repeat(count) { i -> + try { + neighborCallbacks.getBroadcastItem(i).onNeighborTableUpdated(slice) + } catch (_: Exception) { + } + } + } finally { + neighborCallbacks.finishBroadcast() + } + } + + // TetheringManager reflection (API 30+) + + private val classTetheredClient by lazy { + Class.forName("android.net.TetheredClient") + } + private val getMacAddress by lazy { + classTetheredClient.getDeclaredMethod("getMacAddress") + } + private val getAddresses by lazy { + classTetheredClient.getDeclaredMethod("getAddresses") + } + private val classAddressInfo by lazy { + Class.forName("android.net.TetheredClient\$AddressInfo") + } + private val getHostname by lazy { + classAddressInfo.getDeclaredMethod("getHostname") + } + + private fun startTetheringMonitor() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + try { + val manager = getSystemService("tethering") ?: return + tetheringManager = manager + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val registerMethod = manager.javaClass.getMethod( + "registerTetheringEventCallback", + java.util.concurrent.Executor::class.java, + callbackClass, + ) + val proxy = Proxy.newProxyInstance( + callbackClass.classLoader, + arrayOf(callbackClass), + ) { proxyObject, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(proxyObject) + "equals" -> proxyObject === args?.get(0) + "toString" -> + proxyObject.javaClass.name + "@" + + Integer.toHexString(System.identityHashCode(proxyObject)) + "onClientsChanged" -> { + if (args != null) { + @Suppress("UNCHECKED_CAST") + handleClientsChanged(args[0] as Collection<*>) + } + null + } + else -> null + } + } + tetheringCallback = proxy + registerMethod.invoke(manager, Executors.newSingleThreadExecutor(), proxy) + Log.d("RootServer", "TetheringManager monitor started") + } catch (e: Exception) { + Log.e("RootServer", "startTetheringMonitor failed", e) + } + } + + private fun stopTetheringMonitor() { + val manager = tetheringManager ?: return + val callback = tetheringCallback ?: return + try { + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val unregisterMethod = manager.javaClass.getMethod( + "unregisterTetheringEventCallback", + callbackClass, + ) + unregisterMethod.invoke(manager, callback) + } catch (e: Exception) { + Log.e("RootServer", "stopTetheringMonitor failed", e) + } + tetheringCallback = null + tetheringManager = null + hostnameByMAC.clear() + } + + private fun handleClientsChanged(clients: Collection<*>) { + hostnameByMAC.clear() + for (client in clients) { + if (client == null) continue + try { + val mac = getMacAddress.invoke(client).toString().uppercase() + + @Suppress("UNCHECKED_CAST") + val addresses = getAddresses.invoke(client) as List<*> + for (info in addresses) { + if (info == null) continue + val hostname = getHostname.invoke(info) as? String + if (!hostname.isNullOrEmpty()) { + hostnameByMAC[mac] = hostname + } + } + } catch (e: Exception) { + Log.e("RootServer", "handleClientsChanged reflection error", e) + } + } + Log.d("RootServer", "tethered clients updated: ${hostnameByMAC.size} hostnames") + lastNeighborEntries?.let { broadcastEnrichedEntries(it) } } override fun onBind(intent: Intent): IBinder = binder + + override fun onDestroy() { + stopTetheringMonitor() + neighborSubscription?.close() + neighborSubscription = null + neighborCallbacks.kill() + super.onDestroy() + } } From 76772e20f86c872fd6bd0bb8a1933a07f4dd6480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 11:35:56 +0800 Subject: [PATCH 39/45] tools: Tailscale status --- .../nekohasekai/sfa/compose/MainActivity.kt | 2 + .../sfa/compose/navigation/SFANavigation.kt | 73 ++- .../screen/tools/NetworkQualityScreen.kt | 470 ++++++++++++++++++ .../screen/tools/NetworkQualityViewModel.kt | 216 ++++++++ .../screen/tools/OutboundPickerScreen.kt | 283 +++++++++++ .../sfa/compose/screen/tools/ResultItem.kt | 78 +++ .../compose/screen/tools/STUNTestScreen.kt | 317 ++++++++++++ .../compose/screen/tools/STUNTestViewModel.kt | 146 ++++++ .../screen/tools/TailscaleEndpointScreen.kt | 362 ++++++++++++++ .../screen/tools/TailscalePeerScreen.kt | 460 +++++++++++++++++ .../screen/tools/TailscalePingViewModel.kt | 108 ++++ .../screen/tools/TailscaleStatusViewModel.kt | 180 +++++++ .../sfa/compose/screen/tools/ToolsScreen.kt | 128 ++++- .../io/nekohasekai/sfa/utils/CommandClient.kt | 21 + app/src/main/res/values-fa/strings.xml | 77 +++ app/src/main/res/values-ru-rRU/strings.xml | 77 +++ app/src/main/res/values-zh-rCN/strings.xml | 58 ++- app/src/main/res/values-zh-rTW/strings.xml | 65 ++- app/src/main/res/values/strings.xml | 65 +++ 19 files changed, 3179 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 059c64d..80f12bf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -114,6 +114,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController @@ -869,6 +870,7 @@ class MainActivity : logViewModel = logViewModel, groupsViewModel = groupsViewModel, connectionsViewModel = connectionsViewModel, + tailscaleStatusViewModel = tailscaleStatusViewModel, modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index e52ee71..6357e5c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -37,10 +38,16 @@ 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 @@ -73,6 +80,7 @@ fun SFANavHost( logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null, + tailscaleStatusViewModel: TailscaleStatusViewModel? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -220,10 +228,73 @@ fun SFANavHost( } composable(Screen.Tools.route) { - ToolsScreen(navController = navController) + 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, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt new file mode 100644 index 0000000..63d8963 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt @@ -0,0 +1,470 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkQualityScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: NetworkQualityViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + val context = LocalContext.current + + var showConfigURLDialog by remember { mutableStateOf(false) } + var showMaxRuntimeDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.network_quality)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (state.showMeteredWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissMeteredWarning() }, + title = { Text(stringResource(R.string.network_quality_metered_title)) }, + text = { Text(stringResource(R.string.network_quality_metered_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmMeteredStart(vpnRunning) }) { + Text(stringResource(R.string.network_quality_metered_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissMeteredWarning() }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showConfigURLDialog) { + ConfigURLDialog( + currentURL = state.configURL, + onURLChanged = { viewModel.updateConfigURL(it) }, + onDismiss = { showConfigURLDialog = false }, + ) + } + + if (showMaxRuntimeDialog) { + MaxRuntimeDialog( + currentOption = state.maxRuntime, + onOptionSelected = { + viewModel.setMaxRuntime(it) + showMaxRuntimeDialog = false + }, + onDismiss = { showMaxRuntimeDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showConfigURLDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.network_quality_url), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.configURL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_serial), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.serial, + onCheckedChange = { viewModel.setSerial(it) }, + enabled = !state.isRunning, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_http3), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.http3, + onCheckedChange = { viewModel.setHttp3(it) }, + enabled = !state.isRunning, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = !state.isRunning) { showMaxRuntimeDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.network_quality_max_runtime), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + stringResource(state.maxRuntime.labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.network_quality_cancel)) + } + } else { + Button( + onClick = { viewModel.requestStartTest(context, vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.network_quality_start)) + } + } + + if (state.phase >= 0) { + val phaseDownload = Libbox.NetworkQualityPhaseDownload.toInt() + val phaseUpload = Libbox.NetworkQualityPhaseUpload.toInt() + val downloadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseDownload + val uploadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseUpload + val done = state.phase == Libbox.NetworkQualityPhaseDone.toInt() + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.network_quality_idle_latency), + value = if (state.idleLatencyMs > 0) "${state.idleLatencyMs} ms" else null, + isActive = state.phase == Libbox.NetworkQualityPhaseIdle.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download), + value = if (state.downloadCapacity > 0) Libbox.formatBitrate(state.downloadCapacity) else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download_rpm), + value = if (state.downloadRPM > 0) "${state.downloadRPM}" else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadRPMAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload), + value = if (state.uploadCapacity > 0) Libbox.formatBitrate(state.uploadCapacity) else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload_rpm), + value = if (state.uploadRPM > 0) "${state.uploadRPM}" else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadRPMAccuracy).second else null, + ) + } + } + } + } +} + +@Composable +private fun accuracyLabel(value: Int): Pair = when (value) { + Libbox.NetworkQualityAccuracyHigh -> stringResource(R.string.network_quality_confidence_high) to Color.Green + Libbox.NetworkQualityAccuracyMedium -> stringResource(R.string.network_quality_confidence_medium) to Color.Yellow + else -> stringResource(R.string.network_quality_confidence_low) to Color.Red +} + +@Composable +private fun ConfigURLDialog( + currentURL: String, + onURLChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentURL) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_url)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onURLChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun MaxRuntimeDialog( + currentOption: MaxRuntimeOption, + onOptionSelected: (MaxRuntimeOption) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_max_runtime)) }, + text = { + Column { + MaxRuntimeOption.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onOptionSelected(option) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentOption == option, + onClick = { onOptionSelected(option) }, + ) + Text( + text = stringResource(option.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt new file mode 100644 index 0000000..30b0fbc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt @@ -0,0 +1,216 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Context +import android.net.ConnectivityManager +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NetworkQualityProgress +import io.nekohasekai.libbox.NetworkQualityResult +import io.nekohasekai.libbox.NetworkQualityTestHandler +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class MaxRuntimeOption(val seconds: Int, val labelRes: Int) { + THIRTY(30, R.string.network_quality_max_runtime_30s), + SIXTY(60, R.string.network_quality_max_runtime_60s), +} + +data class NetworkQualityState( + val phase: Int = -1, + val idleLatencyMs: Int = 0, + val downloadCapacity: Long = 0, + val uploadCapacity: Long = 0, + val downloadRPM: Int = 0, + val uploadRPM: Int = 0, + val downloadCapacityAccuracy: Int = 0, + val uploadCapacityAccuracy: Int = 0, + val downloadRPMAccuracy: Int = 0, + val uploadRPMAccuracy: Int = 0, + val isRunning: Boolean = false, + val configURL: String = Libbox.NetworkQualityDefaultConfigURL, + val serial: Boolean = false, + val http3: Boolean = false, + val maxRuntime: MaxRuntimeOption = MaxRuntimeOption.THIRTY, + val selectedOutbound: String = "", + val showMeteredWarning: Boolean = false, +) + +class NetworkQualityViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.NetworkQualityTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = NetworkQualityState() + + fun updateConfigURL(url: String) { + updateState { copy(configURL = url) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun setSerial(value: Boolean) { + updateState { copy(serial = value) } + } + + fun setHttp3(value: Boolean) { + updateState { copy(http3 = value) } + } + + fun setMaxRuntime(option: MaxRuntimeOption) { + updateState { copy(maxRuntime = option) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun requestStartTest(context: Context, vpnRunning: Boolean) { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (connectivityManager.isActiveNetworkMetered) { + updateState { copy(showMeteredWarning = true) } + } else { + startTest(vpnRunning) + } + } + + fun dismissMeteredWarning() { + updateState { copy(showMeteredWarning = false) } + } + + fun confirmMeteredStart(vpnRunning: Boolean) { + updateState { copy(showMeteredWarning = false) } + startTest(vpnRunning) + } + + private fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + idleLatencyMs = 0, + downloadCapacity = 0, + uploadCapacity = 0, + downloadRPM = 0, + uploadRPM = 0, + downloadCapacityAccuracy = 0, + uploadCapacityAccuracy = 0, + downloadRPMAccuracy = 0, + uploadRPMAccuracy = 0, + isRunning = true, + ) + } + + val configURL = currentState.configURL + val outboundTag = currentState.selectedOutbound + val serial = currentState.serial + val http3 = currentState.http3 + val maxRuntimeSeconds = currentState.maxRuntime.seconds + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startNetworkQualityTest( + configURL, + outboundTag, + serial, + maxRuntimeSeconds, + http3, + handler, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newNetworkQualityTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(configURL, serial, maxRuntimeSeconds, http3, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): NetworkQualityTestHandler { + return object : NetworkQualityTestHandler { + override fun onProgress(progress: NetworkQualityProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + idleLatencyMs = progress.idleLatencyMs.toInt(), + downloadCapacity = progress.downloadCapacity, + uploadCapacity = progress.uploadCapacity, + downloadRPM = progress.downloadRPM.toInt(), + uploadRPM = progress.uploadRPM.toInt(), + downloadCapacityAccuracy = progress.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = progress.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = progress.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = progress.uploadRPMAccuracy.toInt(), + ) + } + } + } + + override fun onResult(result: NetworkQualityResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.NetworkQualityPhaseDone.toInt(), + idleLatencyMs = result.idleLatencyMs.toInt(), + downloadCapacity = result.downloadCapacity, + uploadCapacity = result.uploadCapacity, + downloadRPM = result.downloadRPM.toInt(), + uploadRPM = result.uploadRPM.toInt(), + downloadCapacityAccuracy = result.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = result.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = result.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = result.uploadRPMAccuracy.toInt(), + isRunning = false, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt new file mode 100644 index 0000000..f14f504 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt @@ -0,0 +1,283 @@ +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.Scaffold +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.utils.CommandClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class OutboundPickerViewModel : + ViewModel(), + CommandClient.Handler { + private val _outbounds = MutableStateFlow>(emptyList()) + val outbounds: StateFlow> = _outbounds.asStateFlow() + + private var commandClient: CommandClient? = null + + fun connect() { + disconnect() + commandClient = CommandClient( + viewModelScope, + CommandClient.ConnectionType.Outbounds, + this, + ) + commandClient?.connect() + } + + fun disconnect() { + commandClient?.disconnect() + commandClient = null + } + + override fun updateOutbounds(outbounds: List) { + _outbounds.value = outbounds.map { GroupItem(it) } + } + + override fun onCleared() { + disconnect() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboundPickerScreen( + navController: NavController, + selectedOutbound: String, +) { + val viewModel: OutboundPickerViewModel = viewModel() + val outbounds by viewModel.outbounds.collectAsState() + var searchText by rememberSaveable { mutableStateOf("") } + + DisposableEffect(Unit) { + viewModel.connect() + onDispose { + viewModel.disconnect() + } + } + + val filteredOutbounds = if (searchText.isEmpty()) { + outbounds + } else { + outbounds.filter { it.tag.contains(searchText, ignoreCase = true) } + } + + fun selectOutbound(tag: String) { + navController.previousBackStackEntry?.savedStateHandle?.set("selected_outbound", tag) + navController.navigateUp() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.tool_outbound)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + OutboundPickerItem( + tag = stringResource(R.string.tool_default_outbound), + isSelected = selectedOutbound.isEmpty(), + onClick = { selectOutbound("") }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(filteredOutbounds, key = { it.tag }) { item -> + OutboundPickerItem( + tag = item.tag, + type = Libbox.proxyDisplayType(item.type), + urlTestDelay = item.urlTestDelay, + isSelected = selectedOutbound == item.tag, + onClick = { selectOutbound(item.tag) }, + ) + } + } + } + } +} + +@Composable +private fun OutboundPickerItem( + tag: String, + type: String? = null, + urlTestDelay: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag, + style = MaterialTheme.typography.bodyLarge, + ) + if (type != null) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (urlTestDelay > 0) { + Text( + text = "${urlTestDelay}ms", + style = MaterialTheme.typography.bodySmall, + color = outboundDelayColor(urlTestDelay), + ) + } + } + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +fun OutboundPickerRow( + selectedOutbound: String, + onClick: () -> Unit, +) { + val displayText = if (selectedOutbound.isEmpty()) { + stringResource(R.string.tool_default_outbound) + } else { + selectedOutbound + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.tool_outbound), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun outboundDelayColor(delay: Int): Color { + val colorScheme = MaterialTheme.colorScheme + return when { + delay < 100 -> colorScheme.tertiary + delay < 300 -> colorScheme.primary + delay < 500 -> colorScheme.secondary + else -> colorScheme.error + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt new file mode 100644 index 0000000..24c02e4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt @@ -0,0 +1,78 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +fun ResultItem( + label: String, + value: String?, + isActive: Boolean, + isRunning: Boolean, + accuracy: String? = null, + valueColor: Color? = null, + accuracyColor: Color? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + when { + value != null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRunning && isActive) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = valueColor ?: Color.Unspecified, + ) + if (accuracy != null) { + Text( + accuracy, + style = MaterialTheme.typography.labelSmall, + color = accuracyColor ?: MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + isRunning && isActive -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + else -> { + Text( + "-", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt new file mode 100644 index 0000000..9681283 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt @@ -0,0 +1,317 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun STUNTestScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: STUNTestViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val vpnRunning = serviceStatus == Status.Started + + var showServerDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.stun_test)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (showServerDialog) { + ServerEditDialog( + currentServer = state.server, + onServerChanged = { viewModel.updateServer(it) }, + onDismiss = { showServerDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showServerDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.stun_server), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.server, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.stun_cancel)) + } + } else { + Button( + onClick = { viewModel.startTest(vpnRunning) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.stun_start)) + } + } + + if (state.phase >= 0) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.stun_external_address), + value = state.externalAddr.ifEmpty { null }, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_latency), + value = if (state.latencyMs > 0) "${state.latencyMs} ms" else null, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + if (state.phase == Libbox.STUNPhaseDone.toInt() && !state.natTypeSupported) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_type_detection), + value = stringResource(R.string.stun_nat_not_supported), + isActive = false, + isRunning = false, + ) + } else { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_mapping), + value = if (state.natMapping > 0) Libbox.formatNATMapping(state.natMapping) else null, + isActive = state.phase == Libbox.STUNPhaseNATMapping.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natMapping > 0) natMappingColor(state.natMapping) else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_filtering), + value = if (state.natFiltering > 0) Libbox.formatNATFiltering(state.natFiltering) else null, + isActive = state.phase == Libbox.STUNPhaseNATFiltering.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natFiltering > 0) natFilteringColor(state.natFiltering) else null, + ) + } + } + } + } + } +} + +private fun natMappingColor(value: Int): Color = when (value) { + Libbox.NATMappingEndpointIndependent.toInt() -> Color.Green + Libbox.NATMappingAddressDependent.toInt() -> Color.Yellow + Libbox.NATMappingAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +private fun natFilteringColor(value: Int): Color = when (value) { + Libbox.NATFilteringEndpointIndependent.toInt() -> Color.Green + Libbox.NATFilteringAddressDependent.toInt() -> Color.Yellow + Libbox.NATFilteringAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +@Composable +private fun ServerEditDialog( + currentServer: String, + onServerChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentServer) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.stun_server)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onServerChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt new file mode 100644 index 0000000..0b7405c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt @@ -0,0 +1,146 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.STUNTestHandler +import io.nekohasekai.libbox.STUNTestProgress +import io.nekohasekai.libbox.STUNTestResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class STUNTestState( + val phase: Int = -1, + val externalAddr: String = "", + val latencyMs: Int = 0, + val natMapping: Int = 0, + val natFiltering: Int = 0, + val natTypeSupported: Boolean = false, + val isRunning: Boolean = false, + val server: String = Libbox.STUNDefaultServer, + val selectedOutbound: String = "", +) + +class STUNTestViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.STUNTest? = null + private var grpcJob: Job? = null + + override fun createInitialState() = STUNTestState() + + fun updateServer(server: String) { + updateState { copy(server = server) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + externalAddr = "", + latencyMs = 0, + natMapping = 0, + natFiltering = 0, + natTypeSupported = false, + isRunning = true, + ) + } + + val server = currentState.server + val outboundTag = currentState.selectedOutbound + val handler = createHandler() + + if (vpnRunning) { + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .startSTUNTest(server, outboundTag, handler) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + grpcJob = null + sendError(e) + } + } + } + } else { + val test = Libbox.newSTUNTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(server, handler) + } + } + } + } + + fun cancelTest() { + grpcJob?.cancel() + grpcJob = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + private fun createHandler(): STUNTestHandler { + return object : STUNTestHandler { + override fun onProgress(progress: STUNTestProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + externalAddr = progress.externalAddr, + latencyMs = progress.latencyMs.toInt(), + natMapping = progress.natMapping.toInt(), + natFiltering = progress.natFiltering.toInt(), + ) + } + } + } + + override fun onResult(result: STUNTestResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.STUNPhaseDone.toInt(), + isRunning = false, + externalAddr = result.externalAddr, + latencyMs = result.latencyMs.toInt(), + natMapping = result.natMapping.toInt(), + natFiltering = result.natFiltering.toInt(), + natTypeSupported = result.natTypeSupported, + ) + } + standaloneTest = null + grpcJob = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + grpcJob = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt new file mode 100644 index 0000000..60e3090 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -0,0 +1,362 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.QRCodeGenerator + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleEndpointScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(endpointTag) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + val context = LocalContext.current + var showAuthQRCode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + val hasNetwork = endpoint.networkName.isNotEmpty() + val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() + val hasAuth = endpoint.authURL.isNotEmpty() + + // Status section + SectionHeader(stringResource(R.string.tailscale_status)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_state), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(stateColor(endpoint.backendState)), + ) + Text( + endpoint.backendState, + style = MaterialTheme.typography.bodyMedium, + color = stateColor(endpoint.backendState), + ) + } + }, + modifier = Modifier.clip( + if (stateIsLast) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + if (hasNetwork) { + val networkIsLast = !hasMagicDNS && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_network), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.networkName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (networkIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasMagicDNS) { + val magicDNSIsLast = !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_magic_dns), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.magicDNSSuffix, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = if (magicDNSIsLast) { + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + } else { + Modifier + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasAuth) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(endpoint.authURL))) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url_qr_code), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { showAuthQRCode = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + // This Device section + if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_this_device)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + PeerItem( + peer = endpoint.selfPeer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + ) + } + } + + // User group sections + for (group in endpoint.userGroups) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(group.displayName.ifEmpty { group.loginName }) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + group.peers.forEachIndexed { index, peer -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + PeerItem( + peer = peer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}", + ) + }, + modifier = when { + group.peers.size == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) + index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + index == group.peers.lastIndex -> Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + else -> Modifier + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (showAuthQRCode && endpoint.authURL.isNotEmpty()) { + val qrBitmap = QRCodeGenerator.rememberBitmap(endpoint.authURL) + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { showAuthQRCode = false }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun PeerItem( + peer: TailscalePeerData, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text( + peer.hostName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = if (peer.tailscaleIPs.isNotEmpty()) { + { + Text( + peer.tailscaleIPs.first(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + null + }, + leadingContent = { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + }, + modifier = modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +private fun stateColor(state: String): Color = when (state) { + "Running" -> Color(0xFF4CAF50) + "NeedsLogin", "NeedsMachineAuth" -> Color(0xFFFF9800) + "Starting" -> Color(0xFFFFEB3B) + else -> Color.Gray +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt new file mode 100644 index 0000000..c821186 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt @@ -0,0 +1,460 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.ktx.clipboardText +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscalePeerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, + peerId: String, +) { + val state by viewModel.uiState.collectAsState() + val peer = viewModel.peer(endpointTag, peerId) + val isSelf = viewModel.endpoint(endpointTag)?.selfPeer?.id == peerId + val pingViewModel: TailscalePingViewModel = viewModel() + val pingState by pingViewModel.uiState.collectAsState() + + DisposableEffect(Unit) { + onDispose { + if (pingState.isRunning) { + pingViewModel.stopPing() + } + } + } + + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + peer?.hostName ?: "", + style = MaterialTheme.typography.titleMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (peer?.online == true) Color(0xFF4CAF50) else Color.Gray, + ), + ) + Text( + if (peer?.online == true) { + stringResource(R.string.tailscale_connected) + } else { + stringResource(R.string.tailscale_not_connected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + if (peer == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var copiedAddress by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Tailscale Addresses section + SectionHeader(stringResource(R.string.tailscale_addresses)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.dnsName.isNotEmpty()) { + AddressRow( + address = Libbox.formatFQDN(peer.dnsName), + label = stringResource(R.string.tailscale_magic_dns), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + for (ip in peer.tailscaleIPs) { + AddressRow( + address = ip, + label = if (ip.contains(":")) { + stringResource(R.string.tailscale_ipv6) + } else { + stringResource(R.string.tailscale_ipv4) + }, + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + } + + // Ping section (not for self peer) + if (!isSelf && peer.online && peer.tailscaleIPs.isNotEmpty()) { + val peerIP = peer.tailscaleIPs.first() + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tailscale_ping), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Surface( + onClick = { + if (pingState.isRunning) { + pingViewModel.stopPing() + } else { + pingViewModel.startPing(endpointTag, peerIP) + } + }, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(width = 44.dp, height = 32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (pingState.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = if (pingState.isRunning) { + stringResource(R.string.tailscale_ping_stop) + } else { + stringResource(R.string.tailscale_ping_start) + }, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + if (pingState.hasResult) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (pingState.isDirect) { + Text( + text = "\u2192 ", + color = Color(0xFF4CAF50), + ) + Text( + text = stringResource(R.string.tailscale_ping_direct), + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = "\u21BB ", + color = Color(0xFFFF9800), + ) + Text( + text = stringResource(R.string.tailscale_ping_derp), + color = Color(0xFFFF9800), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${pingState.latencyMs.toInt()} ms", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + if (pingState.isRunning && pingState.latencyHistory.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + LineChart( + data = pingState.latencyHistory, + lineColor = if (pingState.isDirect) { + Color(0xFF4CAF50) + } else { + Color(0xFF2196F3) + }, + animate = false, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + val maxMs = ( + ( + pingState.latencyHistory.maxOrNull() + ?: 1f + ) * 1.2f + ).toInt().coerceAtLeast(1) + Column( + modifier = Modifier.height(80.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${maxMs}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs * 2 / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "0ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + Text( + text = "No data", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Details section + val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode + if (showDetails) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_details)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + val context = LocalContext.current + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.keyExpiry > 0) { + val expiryText = DateUtils.getRelativeTimeSpanString( + peer.keyExpiry * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = expiryText, + ) + } + if (peer.os.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_os), + value = peer.os, + ) + } + if (peer.exitNode) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_active), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + LaunchedEffect(copiedAddress) { + if (copiedAddress != null) { + delay(2000) + copiedAddress = null + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun AddressRow( + address: String, + label: String, + copied: String?, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + address, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { + clipboardText = address + onCopy(address) + }) { + if (copied == address) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt new file mode 100644 index 0000000..9109e26 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.CommandClient +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscalePingHandler +import io.nekohasekai.libbox.TailscalePingResult +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class TailscalePingState( + val isRunning: Boolean = false, + val hasResult: Boolean = false, + val latencyMs: Double = 0.0, + val isDirect: Boolean = false, + val derpRegionCode: String = "", + val endpoint: String = "", + val latencyHistory: List = emptyList(), +) + +class TailscalePingViewModel : BaseViewModel() { + private val maxHistorySize = 30 + private var commandClient: CommandClient? = null + private var grpcJob: Job? = null + + override fun createInitialState() = TailscalePingState() + + fun startPing(endpointTag: String, peerIP: String) { + updateState { + copy( + isRunning = true, + hasResult = false, + latencyHistory = emptyList(), + ) + } + + val client = Libbox.newStandaloneCommandClient() + commandClient = client + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + client.startTailscalePing( + endpointTag, + peerIP, + object : TailscalePingHandler { + override fun onPingResult(result: TailscalePingResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + if (result.error.isNotEmpty()) return@launch + val newHistory = currentState.latencyHistory.toMutableList() + newHistory.add(result.latencyMs.toFloat()) + if (newHistory.size > maxHistorySize) { + newHistory.removeFirst() + } + updateState { + copy( + hasResult = true, + latencyMs = result.latencyMs, + isDirect = result.isDirect, + derpRegionCode = result.derpRegionCode, + endpoint = result.endpoint, + latencyHistory = newHistory, + ) + } + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + }, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + commandClient = null + grpcJob = null + } + } + } + } + + fun stopPing() { + grpcJob?.cancel() + grpcJob = null + try { + commandClient?.disconnect() + } catch (_: Exception) { + } + commandClient = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + super.onCleared() + stopPing() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt new file mode 100644 index 0000000..1de43e4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt @@ -0,0 +1,180 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.TailscaleStatusHandler +import io.nekohasekai.libbox.TailscaleStatusUpdate +import io.nekohasekai.sfa.compose.base.BaseViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +data class TailscalePeerData( + val id: String, + val hostName: String, + val dnsName: String, + val os: String, + val tailscaleIPs: List, + val online: Boolean, + val exitNode: Boolean, + val exitNodeOption: Boolean, + val active: Boolean, + val rxBytes: Long, + val txBytes: Long, + val keyExpiry: Long, +) + +data class TailscaleUserGroupData( + val id: Long, + val loginName: String, + val displayName: String, + val profilePicURL: String, + val peers: List, +) + +data class TailscaleEndpointData( + val endpointTag: String, + val backendState: String, + val authURL: String, + val networkName: String, + val magicDNSSuffix: String, + val selfPeer: TailscalePeerData?, + val userGroups: List, +) + +data class TailscaleStatusState( + val endpoints: List = emptyList(), + val isSubscribed: Boolean = false, +) + +class TailscaleStatusViewModel : BaseViewModel() { + private var grpcJob: Job? = null + + override fun createInitialState() = TailscaleStatusState() + + fun subscribe() { + if (currentState.isSubscribed) return + updateState { copy(isSubscribed = true) } + + grpcJob = viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient() + .subscribeTailscaleStatus(object : TailscaleStatusHandler { + override fun onStatusUpdate(status: TailscaleStatusUpdate) { + val endpoints = convertUpdate(status) + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = endpoints) } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + sendErrorMessage(message) + } + } + }) + } catch (_: Exception) { + viewModelScope.launch { + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + grpcJob = null + } + } + } + } + + fun cancel() { + grpcJob?.cancel() + grpcJob = null + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + } + + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } + + fun peer(endpointTag: String, peerId: String): TailscalePeerData? { + val ep = endpoint(endpointTag) ?: return null + if (ep.selfPeer?.id == peerId) return ep.selfPeer + for (group in ep.userGroups) { + val found = group.peers.firstOrNull { it.id == peerId } + if (found != null) return found + } + return null + } + + override fun onCleared() { + cancel() + super.onCleared() + } + + private fun convertUpdate(status: TailscaleStatusUpdate): List { + val endpoints = mutableListOf() + val iterator = status.endpoints() + while (iterator.hasNext()) { + endpoints.add(convertEndpoint(iterator.next())) + } + return endpoints + } + + private fun convertEndpoint( + endpoint: io.nekohasekai.libbox.TailscaleEndpointStatus, + ): TailscaleEndpointData { + val userGroups = mutableListOf() + val groupIterator = endpoint.userGroups() + while (groupIterator.hasNext()) { + userGroups.add(convertUserGroup(groupIterator.next())) + } + val self = endpoint.getSelf() + return TailscaleEndpointData( + endpointTag = endpoint.endpointTag, + backendState = endpoint.backendState, + authURL = endpoint.authURL, + networkName = endpoint.networkName, + magicDNSSuffix = endpoint.magicDNSSuffix, + selfPeer = if (self != null) convertPeer(self) else null, + userGroups = userGroups, + ) + } + + private fun convertUserGroup( + group: io.nekohasekai.libbox.TailscaleUserGroup, + ): TailscaleUserGroupData { + val peers = mutableListOf() + val peerIterator = group.peers() + while (peerIterator.hasNext()) { + peers.add(convertPeer(peerIterator.next())) + } + return TailscaleUserGroupData( + id = group.userID, + loginName = group.loginName, + displayName = group.displayName, + profilePicURL = group.profilePicURL, + peers = peers, + ) + } + + private fun convertPeer(peer: io.nekohasekai.libbox.TailscalePeer): TailscalePeerData { + val ips = mutableListOf() + val ipIterator = peer.tailscaleIPs() + while (ipIterator.hasNext()) { + ips.add(ipIterator.next()) + } + val dnsName = peer.getDNSName() + return TailscalePeerData( + id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, + hostName = peer.hostName, + dnsName = dnsName, + os = peer.getOS(), + tailscaleIPs = ips, + online = peer.online, + exitNode = peer.exitNode, + exitNodeOption = peer.exitNodeOption, + active = peer.active, + rxBytes = peer.rxBytes, + txBytes = peer.txBytes, + keyExpiry = peer.keyExpiry, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt index fc3f081..b0e0207 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -1,5 +1,6 @@ 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 @@ -11,7 +12,9 @@ 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 @@ -23,6 +26,7 @@ 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 @@ -35,10 +39,15 @@ 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) { +fun ToolsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + tailscaleViewModel: TailscaleStatusViewModel, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_tools)) }, @@ -47,6 +56,15 @@ fun ToolsScreen(navController: NavController) { 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 @@ -55,6 +73,114 @@ fun ToolsScreen(navController: NavController) { .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, diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 3d5abf9..b70bdd8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -10,6 +10,7 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.OutboundGroupItemIterator import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator @@ -29,6 +30,7 @@ open class CommandClient( private val additionalHandlers = mutableListOf() private var cachedGroups: MutableList? = null + private var cachedOutbounds: List? = null fun addHandler(handler: Handler) { synchronized(additionalHandlers) { @@ -37,6 +39,9 @@ open class CommandClient( cachedGroups?.let { groups -> handler.updateGroups(groups) } + cachedOutbounds?.let { outbounds -> + handler.updateOutbounds(outbounds) + } } } } @@ -57,6 +62,7 @@ open class CommandClient( Log, ClashMode, Connections, + Outbounds, } interface Handler { @@ -74,6 +80,8 @@ open class CommandClient( fun updateGroups(newGroups: MutableList) {} + fun updateOutbounds(outbounds: List) {} + fun initializeClashMode(modeList: List, currentMode: String) {} fun updateClashMode(newMode: String) {} @@ -95,6 +103,7 @@ open class CommandClient( ConnectionType.Log -> Libbox.CommandLog ConnectionType.ClashMode -> Libbox.CommandClashMode ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds } options.addCommand(command) } @@ -142,6 +151,18 @@ open class CommandClient( getAllHandlers().forEach { it.updateGroups(groups) } } + override fun writeOutbounds(message: OutboundGroupItemIterator?) { + if (message == null) { + return + } + val outbounds = mutableListOf() + while (message.hasNext()) { + outbounds.add(message.next()) + } + cachedOutbounds = outbounds + getAllHandlers().forEach { it.updateOutbounds(outbounds) } + } + override fun setDefaultLogLevel(level: Int) { getAllHandlers().forEach { it.setDefaultLogLevel(level) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index d97cf2e..7c0215f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -23,6 +23,8 @@ اقدام شروع لغو انتخاب + بارگذاری مجدد + راه‌اندازی مجدد باز کردن جمع کردن باز کردن همه @@ -200,6 +202,8 @@ پوشه کاری تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ + اندازه حافظه پنهان + پاک‌سازی حافظه پنهان اعلان‌ها فعال‌کردن اعلان نمایش سرعت بلادرنگ در اعلان @@ -279,6 +283,22 @@ نسخه جدید موجود است: %s به‌روزرسانی خودکار دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه + منبع به‌روزرسانی + GitHub + F-Droid + آینه F-Droid + انتخاب خودکار بر اساس تأخیر + در حال تست… + %d ms + ناموفق + + افزودن آینه + نام + URL + سفارشی + URL نامعتبر + افزودن + حذف نصب بی‌صدا @@ -408,6 +428,60 @@ ابزارها + شبکه + کیفیت شبکه + URL + ترتیبی + HTTP/3 + حداکثر زمان اجرا + 30s + 60s + شروع تست + لغو تست + تأخیر بیکاری + دانلود + آپلود + RPM دانلود + RPM آپلود + اطمینان بالا + اطمینان متوسط + اطمینان پایین + اتصال محدود + شما از اتصال محدود استفاده می‌کنید. این تست حجم قابل توجهی داده مصرف خواهد کرد. + ادامه + پیکربندی + نتایج + خروجی + پیش‌فرض + + + + نقاط اتصال + + وضعیت + وضعیت + شبکه + باز کردن لینک احراز هویت + نمایش QR کد لینک احراز هویت + این دستگاه + متصل + متصل نیست + آدرس‌های Tailscale + جزئیات + انقضای کلید + گره خروجی + فعال + + تست STUN + سرور + شروع تست + لغو تست + آدرس خارجی + تأخیر + نگاشت NAT + فیلتر NAT + تشخیص نوع NAT + پشتیبانی نمی‌شود توسط سرور خالی @@ -421,11 +495,14 @@ پیکربندی محلی سرویس شروع نشده است + برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است + برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است گزارش خرابی Go Crash Log JVM Crash Log + هنگام بروز خرابی گزارشی دریافت خواهید کرد. گزارش کمبود حافظه diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 15ec972..50fc5e4 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -23,6 +23,8 @@ Действие Начать Отменить выбор + Перезагрузить + Перезапустить Развернуть Свернуть Развернуть все @@ -200,6 +202,8 @@ Рабочая директория Бета-настройки Отключить предупреждения об устаревании + Размер кэша + Очистить кэш Уведомления Включить уведомления Отображать скорость в реальном времени в уведомлении @@ -279,6 +283,22 @@ Доступна новая версия: %s Автообновление Автоматически загружать и устанавливать обновления в фоне + Источник обновлений + GitHub + F-Droid + Зеркало F-Droid + Автовыбор по задержке + Тестирование… + %d мс + Ошибка + + Добавить зеркало + Имя + URL + Пользовательское + Недопустимый URL + Добавить + Удалить Тихая установка @@ -414,6 +434,60 @@ Инструменты + Сеть + Качество сети + URL + Последовательно + HTTP/3 + Макс. время + 30s + 60s + Начать тест + Остановить тест + Задержка в простое + Загрузка + Отправка + Загрузка RPM + Отправка RPM + Высокая уверенность + Средняя уверенность + Низкая уверенность + Лимитное подключение + Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика. + Продолжить + Конфигурация + Результаты + Исходящий + По умолчанию + + + + Точки подключения + + Статус + Состояние + Сеть + Открыть URL авторизации + Показать QR-код авторизации + Это устройство + Подключено + Не подключено + Адреса Tailscale + Подробности + Срок действия ключа + Выходной узел + Активен + + STUN-тест + Сервер + Начать тест + Остановить тест + Внешний адрес + Задержка + NAT-отображение + NAT-фильтрация + Определение типа NAT + Не поддерживается сервером Пусто @@ -427,11 +501,14 @@ Конфигурация Локальный Служба не запущена + Для применения изменений необходимо перезагрузить сервис + Для применения изменений необходимо перезапустить сервис Отчёт о сбое Go Crash Log JVM Crash Log + Вы получите отчёт при возникновении сбоя. Отчёт о нехватке памяти diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ff99bd0..a6ad323 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -68,7 +68,7 @@ 已启动 - 仪表项目 + 仪表项 内存 协程 上传 @@ -88,7 +88,7 @@ 搜索连接… 关闭所有连接? 全部 - 活跃 + 活动 已关闭 日期 流量 @@ -425,6 +425,60 @@ 工具 + 网络 + 网络质量 + URL + 串行 + HTTP/3 + 最大运行时间 + 30s + 60s + 开始测试 + 取消测试 + 空闲延迟 + 下载 + 上传 + 下载 RPM + 上传 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量计费连接 + 您正在使用按流量计费的连接。此测试将消耗大量数据。 + 继续 + 配置 + 结果 + 出站 + 默认 + + + + 端点 + + 状态 + 状态 + 网络 + 打开认证链接 + 显示认证链接二维码 + 此设备 + 已连接 + 未连接 + Tailscale 地址 + 详情 + 密钥过期 + 出口节点 + 活跃 + + STUN 测试 + 服务器 + 开始测试 + 取消测试 + 外部地址 + 延迟 + NAT 映射 + NAT 过滤 + NAT 类型检测 + 服务器不支持 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7ebd5a1..f3208e7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -23,6 +23,8 @@ 操作 啟動 取消選擇 + 重新載入 + 重新啟動 展開 收合 全部展開 @@ -45,7 +47,7 @@ 預設 - 儀表板 + 儀表 設定檔 日誌 設定 @@ -66,7 +68,7 @@ 已啟動 - 儀表板項目 + 儀表項 記憶體 協程 上傳 @@ -86,7 +88,7 @@ 搜尋連線… 關閉所有連線? 全部 - 活躍 + 活動 已關閉 日期 流量 @@ -426,6 +428,60 @@ 工具 + 網路 + 網路品質 + URL + 序列 + HTTP/3 + 最大執行時間 + 30s + 60s + 開始測試 + 取消測試 + 閒置延遲 + 下載 + 上傳 + 下載 RPM + 上傳 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量計費連線 + 您正在使用按流量計費的連線。此測試將消耗大量數據。 + 繼續 + 配置 + 結果 + 出站 + 默認 + + + + 端點 + + 狀態 + 狀態 + 網路 + 開啟認證連結 + 顯示認證連結 QR 碼 + 此裝置 + 已連線 + 未連線 + Tailscale 位址 + 詳情 + 金鑰到期 + 出口節點 + 活躍 + + STUN 測試 + 伺服器 + 開始測試 + 取消測試 + 外部地址 + 延遲 + NAT 映射 + NAT 過濾 + NAT 類型偵測 + 伺服器不支援 @@ -439,11 +495,14 @@ 配置 本地 服務未啟動 + 需要重新載入服務以套用變更 + 需要重新啟動服務以套用變更 當機報告 Go Crash Log JVM Crash Log + 當發生當機時,您將會收到報告。 記憶體不足報告 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 633755b..70e31fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,71 @@ Tools + Network + Network Quality + URL + Serial + HTTP/3 + Max Runtime + 30s + 60s + Start Test + Cancel Test + Idle Latency + Download + Upload + Download RPM + Upload RPM + Confidence High + Confidence Medium + Confidence Low + Metered Connection + You\'re on a metered connection. This test will use a significant amount of data. + Continue + Configuration + Results + Outbound + Default + + + Tailscale + Tailscale: %s + Endpoints + + Status + State + Network + MagicDNS + Open Auth URL + Show Auth URL QR Code + This Device + Connected + Not Connected + Tailscale Addresses + Details + Key Expiry + OS + Exit Node + Active + IPv4 + IPv6 + Ping + Start + Stop + Direct connection + DERP-relayed connection + + + STUN Test + Server + Start Test + Cancel Test + External Address + Latency + NAT Mapping + NAT Filtering + NAT Type Detection + Not supported by server Empty From 21b59cffef3b547ad5f8c836a0003493be48c0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 21:20:46 +0800 Subject: [PATCH 40/45] Prevent auto-start when unread crash reports exist --- app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt index 013406c..6331123 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -21,6 +21,11 @@ class BootReceiver : BroadcastReceiver() { } GlobalScope.launch(Dispatchers.IO) { if (Settings.startedByUser) { + CrashReportManager.refresh() + if (CrashReportManager.unreadCount.value > 0) { + Settings.startedByUser = false + return@launch + } withContext(Dispatchers.Main) { BoxService.start() } From d4d4091435c6f172eb4823e7b47f7a027c6df517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 10 Apr 2026 22:44:21 +0800 Subject: [PATCH 41/45] Fix blank space above OutboundPickerScreen title --- .../screen/tools/OutboundPickerScreen.kt | 107 +++++++++--------- 1 file changed, 52 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt index f14f504..c20fa4c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -46,6 +45,7 @@ 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 @@ -111,64 +111,61 @@ fun OutboundPickerScreen( navController.navigateUp() } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.tool_outbound)) }, - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - 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 = { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tool_outbound)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { Icon( - imageVector = Icons.Default.Search, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, 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) }, - ) - } + 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) }, + ) } } } From d907b34f493fd0be1b600ed0fe48f0c1b5852797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 11 Apr 2026 10:24:56 +0800 Subject: [PATCH 42/45] Ignore set locale error --- app/src/main/java/io/nekohasekai/sfa/Application.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 1250f80..b5f490a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -9,6 +9,7 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.PowerManager +import android.util.Log import androidx.core.content.getSystemService import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.SetupOptions @@ -41,7 +42,11 @@ class Application : Application() { AppLifecycleObserver.register(this) // Seq.setContext(this) - Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + runCatching { + Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + }.onFailure { + Log.d("Application", "set locale: ${it.message}") + } HookStatusClient.register(this) PrivilegeSettingsClient.register(this) From a5f976913435e52a2697d353ec7e7f2f75de302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 23 Apr 2026 08:12:02 +0800 Subject: [PATCH 43/45] Bump version 1.14.0-alpha.20 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 0d0ef43..d964796 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=662 -VERSION_NAME=1.13.11 +VERSION_CODE=666 +VERSION_NAME=1.14.0-alpha.20 GO_VERSION=go1.25.9 From 804ad7c91cb25b7e8734f203bddb17a7d8d3575d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 3 May 2026 09:07:48 +0800 Subject: [PATCH 44/45] Use new DNS mode --- app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index 6b4c814..942ae83 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -7,6 +7,7 @@ import android.net.VpnService import android.os.Build import android.os.IBinder import android.util.Log +import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.TunOptions import io.nekohasekai.sfa.database.Settings @@ -83,7 +84,12 @@ class VPNService : } if (options.autoRoute) { - builder.addDnsServer(options.dnsServerAddress.value) + if (options.dnsMode.value != Libbox.DNSModeDisabled) { + val dnsServerAddress = options.dnsServerAddress + while (dnsServerAddress.hasNext()) { + builder.addDnsServer(dnsServerAddress.next()) + } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val inet4RouteAddress = options.inet4RouteAddress From a1b58fedd1f643496a291c79b501c60ef4639da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 3 May 2026 09:07:56 +0800 Subject: [PATCH 45/45] Bump version 1.14.0-alpha.21 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index d964796..b1f729f 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=666 -VERSION_NAME=1.14.0-alpha.20 +VERSION_CODE=667 +VERSION_NAME=1.14.0-alpha.21 GO_VERSION=go1.25.9