From e2e2c2ca7b6f3b2d55e597099eaac29ec777cc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 16 Dec 2025 18:25:10 +0800 Subject: [PATCH] Add app settings with update track and auto-check options - Add AppSettingsScreen with update track selection and auto-check toggle - Remove checkUpdateAvailable() as all vendors now support update checking - Add missing Chinese translations for update-related strings --- app/build.gradle | 2 + .../sfa/compose/ComposeActivity.kt | 37 +- .../sfa/compose/component/UpdateDialog.kt | 68 +++ .../sfa/compose/navigation/SFANavigation.kt | 31 ++ .../screen/settings/AppSettingsScreen.kt | 388 ++++++++++++++++++ .../compose/screen/settings/SettingsScreen.kt | 35 +- .../nekohasekai/sfa/constant/SettingsKey.kt | 1 + .../io/nekohasekai/sfa/database/Settings.kt | 1 + .../io/nekohasekai/sfa/ui/MainActivity.kt | 8 +- .../sfa/ui/main/SettingsFragment.kt | 4 - .../sfa/update/UpdateCheckException.kt | 5 + .../io/nekohasekai/sfa/update/UpdateInfo.kt | 10 + .../io/nekohasekai/sfa/update/UpdateState.kt | 19 + .../io/nekohasekai/sfa/update/UpdateTrack.kt | 15 + .../nekohasekai/sfa/vendor/VendorInterface.kt | 15 +- app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values/strings.xml | 9 + .../sfa/vendor/GitHubUpdateChecker.kt | 115 ++++++ .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 90 +++- .../java/io/nekohasekai/sfa/vendor/Vendor.kt | 19 +- build.gradle | 1 + 21 files changed, 862 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt create mode 100644 app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt create mode 100644 app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt diff --git a/app/build.gradle b/app/build.gradle index e209305..617a694 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,6 +6,7 @@ plugins { id "kotlin-parcelize" id "com.google.devtools.ksp" id "org.jetbrains.kotlin.plugin.compose" + id "org.jetbrains.kotlin.plugin.serialization" id "com.github.triplet.play" id "org.jlleitschuh.gradle.ktlint" } @@ -121,6 +122,7 @@ dependencies { implementation "androidx.work:work-runtime-ktx:2.10.3" implementation "androidx.browser:browser:1.9.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" // DO NOT UPDATE (minSdkVersion updated) implementation "com.blacksquircle.ui:editorkit:2.2.0" diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt index a77e906..6356017 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt @@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -58,6 +60,7 @@ import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens @@ -71,6 +74,8 @@ import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission import io.nekohasekai.sfa.ktx.launchCustomTab +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -129,6 +134,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { connection.reconnect() + if (Settings.checkUpdateEnabled) { + lifecycleScope.launch(Dispatchers.IO) { + try { + val updateInfo = Vendor.checkUpdateAsync() + UpdateState.setUpdate(updateInfo) + } catch (_: Exception) { + } + } + } + setContent { SFATheme { SFAApp() @@ -236,6 +251,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { }, onDismiss = { showBackgroundLocationDialog = false }) } + // Handle update available dialog + val updateInfo by UpdateState.updateInfo + var showUpdateDialog by remember { mutableStateOf(true) } + if (showUpdateDialog && updateInfo != null) { + UpdateAvailableDialog( + updateInfo = updateInfo!!, + onDismiss = { showUpdateDialog = false }, + ) + } + // Initialize the dashboard view model and store reference val dashboardViewModel: DashboardViewModel = viewModel() if (!::dashboardViewModel.isInitialized) { @@ -253,6 +278,7 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true val settingsScreenTitle = when (currentDestination?.route) { + "settings/app" -> stringResource(R.string.title_app_settings) "settings/core" -> stringResource(R.string.core) "settings/service" -> stringResource(R.string.service) "settings/profile_override" -> stringResource(R.string.profile_override) @@ -443,10 +469,19 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { bottomBar = { // Only show bottom bar when not in settings sub-screens if (!isSettingsSubScreen) { + val hasUpdate by UpdateState.hasUpdate NavigationBar { bottomNavigationScreens.forEach { screen -> NavigationBarItem( - icon = { Icon(screen.icon, contentDescription = null) }, + icon = { + if (screen == Screen.Settings && hasUpdate) { + BadgedBox(badge = { Badge() }) { + Icon(screen.icon, contentDescription = null) + } + } else { + Icon(screen.icon, contentDescription = null) + } + }, selected = currentDestination?.hierarchy?.any { it.route == screen.route diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt new file mode 100644 index 0000000..bb6cbf7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt @@ -0,0 +1,68 @@ +package io.nekohasekai.sfa.compose.component + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.update.UpdateInfo + +@Composable +fun UpdateAvailableDialog( + updateInfo: UpdateInfo, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.check_update)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.new_version_available, updateInfo.versionName), + style = MaterialTheme.typography.bodyMedium, + ) + if (!updateInfo.releaseNotes.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = updateInfo.releaseNotes.take(500) + + if (updateInfo.releaseNotes.length > 500) "..." else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) + context.startActivity(intent) + onDismiss() + }, + ) { + Text(stringResource(R.string.update)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} 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 c2a4f75..3b11804 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 @@ -11,6 +11,7 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.log.LogScreen import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen @@ -57,6 +58,36 @@ fun SFANavHost( } // Settings subscreens with slide animations + composable( + route = "settings/app", + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + ) { + AppSettingsScreen(navController = navController) + } + composable( route = "settings/core", enterTransition = { 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 new file mode 100644 index 0000000..ead618b --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -0,0 +1,388 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Switch +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.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.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSettingsScreen(navController: NavController) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val hasUpdate by UpdateState.hasUpdate + val updateInfo by UpdateState.updateInfo + val isChecking by UpdateState.isChecking + 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) } + + if (showTrackDialog) { + UpdateTrackDialog( + currentTrack = currentTrack, + onTrackSelected = { track -> + currentTrack = track + scope.launch(Dispatchers.IO) { + Settings.updateTrack = track + } + showTrackDialog = false + }, + onDismiss = { showTrackDialog = false }, + ) + } + + showErrorDialog?.let { messageRes -> + AlertDialog( + onDismissRequest = { showErrorDialog = null }, + title = { Text(stringResource(R.string.check_update)) }, + text = { Text(stringResource(messageRes)) }, + confirmButton = { + TextButton(onClick = { showErrorDialog = null }) { + Text(stringResource(R.string.ok)) + } + }, + ) + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Info Card + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + 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 { Text("New") } + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (Vendor.supportsTrackSelection()) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update_track), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val trackName = 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) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { showTrackDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + ListItem( + headlineContent = { + Text( + stringResource(R.string.check_update_automatic), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Autorenew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = checkUpdateEnabled, + onCheckedChange = { checked -> + checkUpdateEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.checkUpdateEnabled = checked + } + }, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Action Section + Text( + text = stringResource(R.string.action), + 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 { + ListItem( + headlineContent = { + Text( + stringResource(R.string.check_update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (isChecking) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + }, + modifier = + Modifier + .clip( + if (hasUpdate) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + }, + ) + .clickable(enabled = !isChecking) { + scope.launch { + UpdateState.isChecking.value = true + withContext(Dispatchers.IO) { + try { + val result = Vendor.checkUpdateAsync() + UpdateState.setUpdate(result) + if (result == null) { + showErrorDialog = R.string.no_updates_available + } + } catch (_: UpdateCheckException.TrackNotSupported) { + showErrorDialog = R.string.update_track_not_supported + } catch (_: Exception) { + } + } + UpdateState.isChecking.value = false + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (hasUpdate && updateInfo != null) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + updateInfo!!.versionName, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(updateInfo!!.releaseUrl), + ) + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } +} + +@Composable +private fun UpdateTrackDialog( + currentTrack: String, + onTrackSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val tracks = listOf( + "stable" to stringResource(R.string.update_track_stable), + "beta" to stringResource(R.string.update_track_beta), + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.update_track)) }, + text = { + Column { + tracks.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onTrackSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentTrack == value, + onClick = { onTrackSelected(value) }, + ) + Text( + text = label, + 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/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index c178b33..36b18ff 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 @@ -17,9 +17,11 @@ import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SwapHoriz import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -29,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,6 +43,7 @@ import androidx.navigation.NavController import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -48,6 +52,7 @@ import kotlinx.coroutines.launch fun SettingsScreen(navController: NavController) { val context = LocalContext.current val scope = rememberCoroutineScope() + val hasUpdate by UpdateState.hasUpdate Column( modifier = @@ -69,6 +74,35 @@ fun SettingsScreen(navController: NavController) { ), ) { Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.title_app_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasUpdate) { + Badge() + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("settings/app") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( headlineContent = { Text( @@ -85,7 +119,6 @@ fun SettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) .clickable { navController.navigate("settings/core") }, colors = ListItemDefaults.colors( 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 27ed174..02d110d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -4,6 +4,7 @@ object SettingsKey { const val SELECTED_PROFILE = "selected_profile" const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" + const val UPDATE_TRACK = "update_track" const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" const val DYNAMIC_NOTIFICATION = "dynamic_notification" const val USE_COMPOSE_UI = "use_compose_ui" 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 182c11f..f20aaa0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -40,6 +40,7 @@ object Settings { var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } + var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { "stable" } var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt index 6b8a259..70a902c 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt @@ -209,11 +209,9 @@ class MainActivity : } private fun startIntegration() { - if (Vendor.checkUpdateAvailable()) { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.checkUpdateEnabled) { - Vendor.checkUpdate(this@MainActivity, false) - } + lifecycleScope.launch(Dispatchers.IO) { + if (Settings.checkUpdateEnabled) { + Vendor.checkUpdate(this@MainActivity, false) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt index c44a4c1..ccea0a0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt @@ -82,10 +82,6 @@ class SettingsFragment : Fragment() { } } } - if (!Vendor.checkUpdateAvailable()) { - binding.checkUpdateEnabled.isVisible = false - binding.checkUpdateButton.isVisible = false - } binding.checkUpdateEnabled.addTextChangedListener { lifecycleScope.launch(Dispatchers.IO) { val newValue = EnabledType.valueOf(requireContext(), it).boolValue diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt new file mode 100644 index 0000000..63d2b61 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.update + +sealed class UpdateCheckException : Exception() { + class TrackNotSupported : UpdateCheckException() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt new file mode 100644 index 0000000..05532ca --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sfa.update + +data class UpdateInfo( + val versionCode: Int, + val versionName: String, + val downloadUrl: String, + val releaseUrl: String, + val releaseNotes: String?, + val isPrerelease: Boolean, +) diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt new file mode 100644 index 0000000..f20eab7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.update + +import androidx.compose.runtime.mutableStateOf + +object UpdateState { + val hasUpdate = mutableStateOf(false) + val updateInfo = mutableStateOf(null) + val isChecking = mutableStateOf(false) + + fun setUpdate(info: UpdateInfo?) { + updateInfo.value = info + hasUpdate.value = info != null + } + + fun clear() { + hasUpdate.value = false + updateInfo.value = null + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt new file mode 100644 index 0000000..23c75d7 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt @@ -0,0 +1,15 @@ +package io.nekohasekai.sfa.update + +enum class UpdateTrack { + STABLE, + BETA; + + companion object { + fun fromString(value: String): UpdateTrack { + return when (value.lowercase()) { + "beta" -> BETA + else -> STABLE + } + } + } +} 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 e204f3e..229ec4f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -2,10 +2,9 @@ package io.nekohasekai.sfa.vendor import android.app.Activity import androidx.camera.core.ImageAnalysis +import io.nekohasekai.sfa.update.UpdateInfo interface VendorInterface { - fun checkUpdateAvailable(): Boolean - fun checkUpdate( activity: Activity, byUser: Boolean, @@ -21,4 +20,16 @@ interface VendorInterface { * @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 + + /** + * Check for updates asynchronously + * @return UpdateInfo if update is available, null otherwise + */ + fun checkUpdateAsync(): UpdateInfo? = null } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e973f44..c6c7546 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -86,6 +86,15 @@ 自动检查更新 检查更新 没有可用的更新 + 有新版本可用:%s + 更新 + 更新轨道 + 稳定版 + 测试版 + 当前轨道尚不支持检查更新 + 版本 %s + 应用版本 + 操作 隐私政策 应用 内存限制 @@ -225,6 +234,7 @@ 更新配置文件 更多选项 编辑 + 完成 另存为文件 分享为文件 服务 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d2fe07..59d1123 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,6 +128,15 @@ Automatic Update Check Check Update No updates available + New version available: %s + Update + Update Track + Stable + Beta + Current track does not support update checking yet + Version %s + App version + Action Privacy Policy App Memory Limit diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt new file mode 100644 index 0000000..9cf8047 --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -0,0 +1,115 @@ +package io.nekohasekai.sfa.vendor + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.ktx.unwrap +import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.Closeable + +class GitHubUpdateChecker : Closeable { + companion object { + private const val RELEASES_URL = "https://api.github.com/repos/SagerNet/sing-box/releases" + private const val METADATA_FILENAME = "SFA-version-metadata.json" + } + + private val client = Libbox.newHTTPClient().apply { + modernTLS() + keepAlive() + } + + private val json = Json { ignoreUnknownKeys = true } + + fun checkUpdate(track: UpdateTrack): UpdateInfo? { + val includePrerelease = track == UpdateTrack.BETA + val release = getLatestRelease(includePrerelease) ?: return null + + if (!release.assets.any { it.name == METADATA_FILENAME }) { + throw UpdateCheckException.TrackNotSupported() + } + + val metadata = downloadMetadata(release)!! + + if (metadata.versionCode <= BuildConfig.VERSION_CODE) { + return null + } + + val apkAsset = release.assets.find { asset -> + asset.name.endsWith(".apk") && !asset.name.contains("play") + } + + return UpdateInfo( + versionCode = metadata.versionCode, + versionName = metadata.versionName, + downloadUrl = apkAsset?.browserDownloadUrl ?: release.htmlUrl, + releaseUrl = release.htmlUrl, + releaseNotes = release.body, + isPrerelease = release.prerelease, + ) + } + + private fun getLatestRelease(includePrerelease: Boolean): GitHubRelease? { + val request = client.newRequest() + request.setURL(RELEASES_URL) + request.setHeader("Accept", "application/vnd.github.v3+json") + request.setUserAgent(HTTPClient.userAgent) + + val response = request.execute() + val content = response.content.unwrap + + val releases = json.decodeFromString>(content) + + return if (includePrerelease) { + releases.firstOrNull() + } else { + releases.firstOrNull { !it.prerelease && !it.draft } + } + } + + private fun downloadMetadata(release: GitHubRelease): VersionMetadata? { + val metadataAsset = release.assets.find { it.name == METADATA_FILENAME } + ?: return null + + val request = client.newRequest() + request.setURL(metadataAsset.browserDownloadUrl) + request.setUserAgent(HTTPClient.userAgent) + + val response = request.execute() + val content = response.content.unwrap + + return json.decodeFromString(content) + } + + override fun close() { + client.close() + } + + @Serializable + data class GitHubRelease( + @SerialName("tag_name") val tagName: String = "", + val name: String = "", + val body: String? = null, + val draft: Boolean = false, + val prerelease: Boolean = false, + @SerialName("html_url") val htmlUrl: String = "", + val assets: List = emptyList(), + ) + + @Serializable + data class GitHubAsset( + val name: String = "", + @SerialName("browser_download_url") val browserDownloadUrl: String = "", + val size: Long = 0, + ) + + @Serializable + data class VersionMetadata( + @SerialName("version_code") val versionCode: Int = 0, + @SerialName("version_name") val versionName: String = "", + ) +} 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 b542108..d00b2a5 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -1,17 +1,89 @@ package io.nekohasekai.sfa.vendor import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.util.Log import androidx.camera.core.ImageAnalysis +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateTrack object Vendor : VendorInterface { - override fun checkUpdateAvailable(): Boolean { - return false - } + private const val TAG = "Vendor" override fun checkUpdate( activity: Activity, byUser: Boolean, ) { + try { + val updateInfo = checkUpdateAsync() + if (updateInfo != null) { + activity.runOnUiThread { + showUpdateDialog(activity, updateInfo) + } + } else if (byUser) { + activity.runOnUiThread { + showNoUpdatesDialog(activity) + } + } + } catch (e: UpdateCheckException.TrackNotSupported) { + Log.d(TAG, "checkUpdate: track not supported") + if (byUser) { + activity.runOnUiThread { + showTrackNotSupportedDialog(activity) + } + } + } catch (e: Exception) { + Log.e(TAG, "checkUpdate: ", e) + if (byUser) { + activity.runOnUiThread { + showNoUpdatesDialog(activity) + } + } + } + } + + private fun showUpdateDialog(activity: Activity, updateInfo: UpdateInfo) { + val message = buildString { + append(activity.getString(R.string.new_version_available, updateInfo.versionName)) + if (!updateInfo.releaseNotes.isNullOrBlank()) { + append("\n\n") + append(updateInfo.releaseNotes.take(500)) + if (updateInfo.releaseNotes.length > 500) { + append("...") + } + } + } + + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.check_update) + .setMessage(message) + .setPositiveButton(R.string.update) { _, _ -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) + activity.startActivity(intent) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun showNoUpdatesDialog(activity: Activity) { + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.check_update) + .setMessage(R.string.no_updates_available) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun showTrackNotSupportedDialog(activity: Activity) { + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.check_update) + .setMessage(R.string.update_track_not_supported) + .setPositiveButton(R.string.ok, null) + .show() } override fun createQRCodeAnalyzer( @@ -22,7 +94,17 @@ object Vendor : VendorInterface { } override fun isPerAppProxyAvailable(): Boolean { - // Per-app Proxy is available for non-Play Store builds return true } + + override fun supportsTrackSelection(): Boolean { + return true + } + + override fun checkUpdateAsync(): UpdateInfo? { + val track = UpdateTrack.fromString(Settings.updateTrack) + return GitHubUpdateChecker().use { checker -> + checker.checkUpdate(track) + } + } } 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 d7a4cad..713fd46 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -12,14 +12,12 @@ import com.google.android.play.core.install.model.InstallStatus import com.google.android.play.core.install.model.UpdateAvailability import com.google.mlkit.common.MlKitException import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateState object Vendor : VendorInterface { private const val TAG = "Vendor" - override fun checkUpdateAvailable(): Boolean { - return true - } - override fun checkUpdate( activity: Activity, byUser: Boolean, @@ -30,6 +28,7 @@ object Vendor : VendorInterface { when (appUpdateInfo.updateAvailability()) { UpdateAvailability.UPDATE_NOT_AVAILABLE -> { Log.d(TAG, "checkUpdate: not available") + UpdateState.clear() if (byUser) activity.showNoUpdatesDialog() } @@ -44,6 +43,7 @@ object Vendor : VendorInterface { UpdateAvailability.UPDATE_AVAILABLE -> { Log.d(TAG, "checkUpdate: available") + UpdateState.hasUpdate.value = true if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) { appUpdateManager.startUpdateFlow( appUpdateInfo, @@ -97,4 +97,15 @@ object Vendor : VendorInterface { // Per-app Proxy is disabled for Play Store builds due to QUERY_ALL_PACKAGES permission restriction return false } + + override fun supportsTrackSelection(): Boolean { + // Play Store doesn't support track selection + return false + } + + override fun checkUpdateAsync(): UpdateInfo? { + // Play Store updates are handled by the Play Core library + // We can't get version info in the same way as GitHub + return null + } } diff --git a/build.gradle b/build.gradle index 1ccf789..9014c85 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ plugins { id 'com.google.devtools.ksp' version '2.2.0-2.0.2' apply false id 'com.github.triplet.play' version '3.12.1' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.2.0' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0' apply false id 'org.jlleitschuh.gradle.ktlint' version '13.1.0' apply false id 'io.gitlab.arturbosch.detekt' version '1.23.8' }