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:
@@ -6,6 +6,7 @@ plugins {
|
|||||||
id "kotlin-parcelize"
|
id "kotlin-parcelize"
|
||||||
id "com.google.devtools.ksp"
|
id "com.google.devtools.ksp"
|
||||||
id "org.jetbrains.kotlin.plugin.compose"
|
id "org.jetbrains.kotlin.plugin.compose"
|
||||||
|
id "org.jetbrains.kotlin.plugin.serialization"
|
||||||
id "com.github.triplet.play"
|
id "com.github.triplet.play"
|
||||||
id "org.jlleitschuh.gradle.ktlint"
|
id "org.jlleitschuh.gradle.ktlint"
|
||||||
}
|
}
|
||||||
@@ -121,6 +122,7 @@ dependencies {
|
|||||||
implementation "androidx.work:work-runtime-ktx:2.10.3"
|
implementation "androidx.work:work-runtime-ktx:2.10.3"
|
||||||
implementation "androidx.browser:browser:1.9.0"
|
implementation "androidx.browser:browser:1.9.0"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
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)
|
// DO NOT UPDATE (minSdkVersion updated)
|
||||||
implementation "com.blacksquircle.ui:editorkit:2.2.0"
|
implementation "com.blacksquircle.ui:editorkit:2.2.0"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.Pause
|
|||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -58,6 +60,7 @@ import io.nekohasekai.sfa.bg.ServiceConnection
|
|||||||
import io.nekohasekai.sfa.bg.ServiceNotification
|
import io.nekohasekai.sfa.bg.ServiceNotification
|
||||||
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
import io.nekohasekai.sfa.compose.base.GlobalEventBus
|
||||||
import io.nekohasekai.sfa.compose.base.UiEvent
|
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.SFANavHost
|
||||||
import io.nekohasekai.sfa.compose.navigation.Screen
|
import io.nekohasekai.sfa.compose.navigation.Screen
|
||||||
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
|
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.database.Settings
|
||||||
import io.nekohasekai.sfa.ktx.hasPermission
|
import io.nekohasekai.sfa.ktx.hasPermission
|
||||||
import io.nekohasekai.sfa.ktx.launchCustomTab
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -129,6 +134,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
|
|
||||||
connection.reconnect()
|
connection.reconnect()
|
||||||
|
|
||||||
|
if (Settings.checkUpdateEnabled) {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val updateInfo = Vendor.checkUpdateAsync()
|
||||||
|
UpdateState.setUpdate(updateInfo)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SFATheme {
|
SFATheme {
|
||||||
SFAApp()
|
SFAApp()
|
||||||
@@ -236,6 +251,16 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
}, onDismiss = { showBackgroundLocationDialog = false })
|
}, 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
|
// Initialize the dashboard view model and store reference
|
||||||
val dashboardViewModel: DashboardViewModel = viewModel()
|
val dashboardViewModel: DashboardViewModel = viewModel()
|
||||||
if (!::dashboardViewModel.isInitialized) {
|
if (!::dashboardViewModel.isInitialized) {
|
||||||
@@ -253,6 +278,7 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
|
val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
|
||||||
val settingsScreenTitle =
|
val settingsScreenTitle =
|
||||||
when (currentDestination?.route) {
|
when (currentDestination?.route) {
|
||||||
|
"settings/app" -> stringResource(R.string.title_app_settings)
|
||||||
"settings/core" -> stringResource(R.string.core)
|
"settings/core" -> stringResource(R.string.core)
|
||||||
"settings/service" -> stringResource(R.string.service)
|
"settings/service" -> stringResource(R.string.service)
|
||||||
"settings/profile_override" -> stringResource(R.string.profile_override)
|
"settings/profile_override" -> stringResource(R.string.profile_override)
|
||||||
@@ -443,10 +469,19 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
// Only show bottom bar when not in settings sub-screens
|
// Only show bottom bar when not in settings sub-screens
|
||||||
if (!isSettingsSubScreen) {
|
if (!isSettingsSubScreen) {
|
||||||
|
val hasUpdate by UpdateState.hasUpdate
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
bottomNavigationScreens.forEach { screen ->
|
bottomNavigationScreens.forEach { screen ->
|
||||||
NavigationBarItem(
|
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 =
|
selected =
|
||||||
currentDestination?.hierarchy?.any {
|
currentDestination?.hierarchy?.any {
|
||||||
it.route == screen.route
|
it.route == screen.route
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.dashboard.DashboardViewModel
|
||||||
import io.nekohasekai.sfa.compose.screen.log.LogScreen
|
import io.nekohasekai.sfa.compose.screen.log.LogScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.log.LogViewModel
|
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.CoreSettingsScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
|
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
|
||||||
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
|
import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
|
||||||
@@ -57,6 +58,36 @@ fun SFANavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Settings subscreens with slide animations
|
// 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(
|
composable(
|
||||||
route = "settings/core",
|
route = "settings/core",
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ import androidx.compose.material.icons.outlined.Code
|
|||||||
import androidx.compose.material.icons.outlined.Description
|
import androidx.compose.material.icons.outlined.Description
|
||||||
import androidx.compose.material.icons.outlined.Favorite
|
import androidx.compose.material.icons.outlined.Favorite
|
||||||
import androidx.compose.material.icons.outlined.FilterAlt
|
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.Settings
|
||||||
import androidx.compose.material.icons.outlined.SwapHoriz
|
import androidx.compose.material.icons.outlined.SwapHoriz
|
||||||
import androidx.compose.material.icons.outlined.Tune
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -40,6 +43,7 @@ import androidx.navigation.NavController
|
|||||||
import io.nekohasekai.sfa.BuildConfig
|
import io.nekohasekai.sfa.BuildConfig
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Settings
|
import io.nekohasekai.sfa.database.Settings
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun SettingsScreen(navController: NavController) {
|
fun SettingsScreen(navController: NavController) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val hasUpdate by UpdateState.hasUpdate
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -69,6 +74,35 @@ fun SettingsScreen(navController: NavController) {
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
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(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -85,7 +119,6 @@ fun SettingsScreen(navController: NavController) {
|
|||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
|
||||||
.clickable { navController.navigate("settings/core") },
|
.clickable { navController.navigate("settings/core") },
|
||||||
colors =
|
colors =
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ object SettingsKey {
|
|||||||
const val SELECTED_PROFILE = "selected_profile"
|
const val SELECTED_PROFILE = "selected_profile"
|
||||||
const val SERVICE_MODE = "service_mode"
|
const val SERVICE_MODE = "service_mode"
|
||||||
const val CHECK_UPDATE_ENABLED = "check_update_enabled"
|
const val CHECK_UPDATE_ENABLED = "check_update_enabled"
|
||||||
|
const val UPDATE_TRACK = "update_track"
|
||||||
const val DISABLE_MEMORY_LIMIT = "disable_memory_limit"
|
const val DISABLE_MEMORY_LIMIT = "disable_memory_limit"
|
||||||
const val DYNAMIC_NOTIFICATION = "dynamic_notification"
|
const val DYNAMIC_NOTIFICATION = "dynamic_notification"
|
||||||
const val USE_COMPOSE_UI = "use_compose_ui"
|
const val USE_COMPOSE_UI = "use_compose_ui"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ object Settings {
|
|||||||
var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER)
|
var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER)
|
||||||
|
|
||||||
var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true }
|
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 disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT)
|
||||||
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
|
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
|
||||||
var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }
|
var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }
|
||||||
|
|||||||
@@ -209,11 +209,9 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startIntegration() {
|
private fun startIntegration() {
|
||||||
if (Vendor.checkUpdateAvailable()) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
if (Settings.checkUpdateEnabled) {
|
||||||
if (Settings.checkUpdateEnabled) {
|
Vendor.checkUpdate(this@MainActivity, false)
|
||||||
Vendor.checkUpdate(this@MainActivity, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ class SettingsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Vendor.checkUpdateAvailable()) {
|
|
||||||
binding.checkUpdateEnabled.isVisible = false
|
|
||||||
binding.checkUpdateButton.isVisible = false
|
|
||||||
}
|
|
||||||
binding.checkUpdateEnabled.addTextChangedListener {
|
binding.checkUpdateEnabled.addTextChangedListener {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
|
val newValue = EnabledType.valueOf(requireContext(), it).boolValue
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package io.nekohasekai.sfa.update
|
||||||
|
|
||||||
|
sealed class UpdateCheckException : Exception() {
|
||||||
|
class TrackNotSupported : UpdateCheckException()
|
||||||
|
}
|
||||||
10
app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt
Normal file
10
app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt
Normal 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,
|
||||||
|
)
|
||||||
19
app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt
Normal file
19
app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt
Normal file
15
app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ package io.nekohasekai.sfa.vendor
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import io.nekohasekai.sfa.update.UpdateInfo
|
||||||
|
|
||||||
interface VendorInterface {
|
interface VendorInterface {
|
||||||
fun checkUpdateAvailable(): Boolean
|
|
||||||
|
|
||||||
fun checkUpdate(
|
fun checkUpdate(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
byUser: Boolean,
|
byUser: Boolean,
|
||||||
@@ -21,4 +20,16 @@ interface VendorInterface {
|
|||||||
* @return true if available, false if disabled (e.g., for Play Store builds)
|
* @return true if available, false if disabled (e.g., for Play Store builds)
|
||||||
*/
|
*/
|
||||||
fun isPerAppProxyAvailable(): Boolean = true
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,15 @@
|
|||||||
<string name="check_update_automatic">自动检查更新</string>
|
<string name="check_update_automatic">自动检查更新</string>
|
||||||
<string name="check_update">检查更新</string>
|
<string name="check_update">检查更新</string>
|
||||||
<string name="no_updates_available">没有可用的更新</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="privacy_policy">隐私政策</string>
|
||||||
<string name="title_app_settings">应用</string>
|
<string name="title_app_settings">应用</string>
|
||||||
<string name="memory_limit">内存限制</string>
|
<string name="memory_limit">内存限制</string>
|
||||||
@@ -225,6 +234,7 @@
|
|||||||
<string name="update_profile">更新配置文件</string>
|
<string name="update_profile">更新配置文件</string>
|
||||||
<string name="more_options">更多选项</string>
|
<string name="more_options">更多选项</string>
|
||||||
<string name="edit">编辑</string>
|
<string name="edit">编辑</string>
|
||||||
|
<string name="done">完成</string>
|
||||||
<string name="save_as_file">另存为文件</string>
|
<string name="save_as_file">另存为文件</string>
|
||||||
<string name="share_as_file">分享为文件</string>
|
<string name="share_as_file">分享为文件</string>
|
||||||
<string name="service">服务</string>
|
<string name="service">服务</string>
|
||||||
|
|||||||
@@ -128,6 +128,15 @@
|
|||||||
<string name="check_update_automatic">Automatic Update Check</string>
|
<string name="check_update_automatic">Automatic Update Check</string>
|
||||||
<string name="check_update">Check Update</string>
|
<string name="check_update">Check Update</string>
|
||||||
<string name="no_updates_available">No updates available</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="privacy_policy">Privacy Policy</string>
|
||||||
<string name="title_app_settings">App</string>
|
<string name="title_app_settings">App</string>
|
||||||
<string name="memory_limit">Memory Limit</string>
|
<string name="memory_limit">Memory Limit</string>
|
||||||
|
|||||||
115
app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt
vendored
Normal file
115
app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt
vendored
Normal 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 = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,89 @@
|
|||||||
package io.nekohasekai.sfa.vendor
|
package io.nekohasekai.sfa.vendor
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.camera.core.ImageAnalysis
|
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 {
|
object Vendor : VendorInterface {
|
||||||
override fun checkUpdateAvailable(): Boolean {
|
private const val TAG = "Vendor"
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkUpdate(
|
override fun checkUpdate(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
byUser: Boolean,
|
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(
|
override fun createQRCodeAnalyzer(
|
||||||
@@ -22,7 +94,17 @@ object Vendor : VendorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun isPerAppProxyAvailable(): Boolean {
|
override fun isPerAppProxyAvailable(): Boolean {
|
||||||
// Per-app Proxy is available for non-Play Store builds
|
|
||||||
return true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.android.play.core.install.model.UpdateAvailability
|
||||||
import com.google.mlkit.common.MlKitException
|
import com.google.mlkit.common.MlKitException
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
|
import io.nekohasekai.sfa.update.UpdateInfo
|
||||||
|
import io.nekohasekai.sfa.update.UpdateState
|
||||||
|
|
||||||
object Vendor : VendorInterface {
|
object Vendor : VendorInterface {
|
||||||
private const val TAG = "Vendor"
|
private const val TAG = "Vendor"
|
||||||
|
|
||||||
override fun checkUpdateAvailable(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkUpdate(
|
override fun checkUpdate(
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
byUser: Boolean,
|
byUser: Boolean,
|
||||||
@@ -30,6 +28,7 @@ object Vendor : VendorInterface {
|
|||||||
when (appUpdateInfo.updateAvailability()) {
|
when (appUpdateInfo.updateAvailability()) {
|
||||||
UpdateAvailability.UPDATE_NOT_AVAILABLE -> {
|
UpdateAvailability.UPDATE_NOT_AVAILABLE -> {
|
||||||
Log.d(TAG, "checkUpdate: not available")
|
Log.d(TAG, "checkUpdate: not available")
|
||||||
|
UpdateState.clear()
|
||||||
if (byUser) activity.showNoUpdatesDialog()
|
if (byUser) activity.showNoUpdatesDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +43,7 @@ object Vendor : VendorInterface {
|
|||||||
|
|
||||||
UpdateAvailability.UPDATE_AVAILABLE -> {
|
UpdateAvailability.UPDATE_AVAILABLE -> {
|
||||||
Log.d(TAG, "checkUpdate: available")
|
Log.d(TAG, "checkUpdate: available")
|
||||||
|
UpdateState.hasUpdate.value = true
|
||||||
if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
|
if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
|
||||||
appUpdateManager.startUpdateFlow(
|
appUpdateManager.startUpdateFlow(
|
||||||
appUpdateInfo,
|
appUpdateInfo,
|
||||||
@@ -97,4 +97,15 @@ object Vendor : VendorInterface {
|
|||||||
// Per-app Proxy is disabled for Play Store builds due to QUERY_ALL_PACKAGES permission restriction
|
// Per-app Proxy is disabled for Play Store builds due to QUERY_ALL_PACKAGES permission restriction
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ plugins {
|
|||||||
id 'com.google.devtools.ksp' version '2.2.0-2.0.2' apply false
|
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 '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.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 'org.jlleitschuh.gradle.ktlint' version '13.1.0' apply false
|
||||||
id 'io.gitlab.arturbosch.detekt' version '1.23.8'
|
id 'io.gitlab.arturbosch.detekt' version '1.23.8'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user