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
This commit is contained in:
世界
2025-12-16 18:25:10 +08:00
parent be175ccd73
commit e2e2c2ca7b
21 changed files with 862 additions and 21 deletions

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package io.nekohasekai.sfa.update
sealed class UpdateCheckException : Exception() {
class TrackNotSupported : UpdateCheckException()
}

View File

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

View File

@@ -0,0 +1,19 @@
package io.nekohasekai.sfa.update
import androidx.compose.runtime.mutableStateOf
object UpdateState {
val hasUpdate = mutableStateOf(false)
val updateInfo = mutableStateOf<UpdateInfo?>(null)
val isChecking = mutableStateOf(false)
fun setUpdate(info: UpdateInfo?) {
updateInfo.value = info
hasUpdate.value = info != null
}
fun clear() {
hasUpdate.value = false
updateInfo.value = null
}
}

View File

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

View File

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

View File

@@ -86,6 +86,15 @@
<string name="check_update_automatic">自动检查更新</string>
<string name="check_update">检查更新</string>
<string name="no_updates_available">没有可用的更新</string>
<string name="new_version_available">有新版本可用:%s</string>
<string name="update">更新</string>
<string name="update_track">更新轨道</string>
<string name="update_track_stable">稳定版</string>
<string name="update_track_beta">测试版</string>
<string name="update_track_not_supported">当前轨道尚不支持检查更新</string>
<string name="app_version">版本 %s</string>
<string name="app_version_title">应用版本</string>
<string name="action">操作</string>
<string name="privacy_policy">隐私政策</string>
<string name="title_app_settings">应用</string>
<string name="memory_limit">内存限制</string>
@@ -225,6 +234,7 @@
<string name="update_profile">更新配置文件</string>
<string name="more_options">更多选项</string>
<string name="edit">编辑</string>
<string name="done">完成</string>
<string name="save_as_file">另存为文件</string>
<string name="share_as_file">分享为文件</string>
<string name="service">服务</string>

View File

@@ -128,6 +128,15 @@
<string name="check_update_automatic">Automatic Update Check</string>
<string name="check_update">Check Update</string>
<string name="no_updates_available">No updates available</string>
<string name="new_version_available">New version available: %s</string>
<string name="update">Update</string>
<string name="update_track">Update Track</string>
<string name="update_track_stable">Stable</string>
<string name="update_track_beta">Beta</string>
<string name="update_track_not_supported">Current track does not support update checking yet</string>
<string name="app_version">Version %s</string>
<string name="app_version_title">App version</string>
<string name="action">Action</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="title_app_settings">App</string>
<string name="memory_limit">Memory Limit</string>

View File

@@ -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<List<GitHubRelease>>(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<VersionMetadata>(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<GitHubAsset> = 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 = "",
)
}

View File

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

View File

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