Improve update system

This commit is contained in:
世界
2025-12-18 22:38:33 +08:00
parent 2da0674c33
commit 72c7794ba9
21 changed files with 1391 additions and 28 deletions

View File

@@ -20,8 +20,18 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -55,6 +65,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.bg.ServiceNotification
@@ -134,6 +145,7 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
connection.reconnect()
UpdateState.loadFromCache()
if (Settings.checkUpdateEnabled) {
lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -251,13 +263,126 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
}, onDismiss = { showBackgroundLocationDialog = false })
}
// Handle update check prompt dialog (shown only once on first launch)
var showUpdateCheckPrompt by remember { mutableStateOf(!Settings.updateCheckPrompted) }
if (showUpdateCheckPrompt) {
AlertDialog(
onDismissRequest = {
Settings.updateCheckPrompted = true
showUpdateCheckPrompt = false
},
title = { Text(stringResource(R.string.check_update)) },
text = {
MarkdownText(
markdown = stringResource(
if (BuildConfig.FLAVOR == "play") R.string.check_update_prompt_play
else R.string.check_update_prompt_github
),
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(onClick = {
Settings.updateCheckPrompted = true
Settings.checkUpdateEnabled = true
showUpdateCheckPrompt = false
scope.launch(Dispatchers.IO) {
try {
val result = Vendor.checkUpdateAsync()
UpdateState.setUpdate(result)
} catch (_: Exception) {
}
}
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
Settings.updateCheckPrompted = true
showUpdateCheckPrompt = false
}) {
Text(stringResource(R.string.no_thanks))
}
},
)
}
// Handle update available dialog
val updateInfo by UpdateState.updateInfo
val shouldShowUpdateDialog = updateInfo != null &&
updateInfo!!.versionCode > Settings.lastShownUpdateVersion
var showUpdateDialog by remember { mutableStateOf(true) }
if (showUpdateDialog && updateInfo != null) {
// Download dialog state
var showDownloadDialog by remember { mutableStateOf(false) }
var downloadJob by remember { mutableStateOf<Job?>(null) }
var downloadError by remember { mutableStateOf<String?>(null) }
if (showUpdateDialog && shouldShowUpdateDialog) {
UpdateAvailableDialog(
updateInfo = updateInfo!!,
onDismiss = { showUpdateDialog = false },
onDismiss = {
Settings.lastShownUpdateVersion = updateInfo!!.versionCode
showUpdateDialog = false
},
onUpdate = {
showDownloadDialog = true
downloadError = null
downloadJob = scope.launch {
try {
val result = withContext(Dispatchers.IO) {
Vendor.downloadAndInstall(
this@ComposeActivity,
updateInfo!!.downloadUrl,
)
}
if (result.isFailure) {
downloadError = result.exceptionOrNull()?.message
} else {
showDownloadDialog = false
}
} catch (e: Exception) {
downloadError = e.message
}
}
},
)
}
// Download progress dialog
if (showDownloadDialog) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.update)) },
text = {
Column {
if (downloadError != null) {
Text(
downloadError!!,
color = MaterialTheme.colorScheme.error,
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.downloading))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
downloadJob?.cancel()
downloadJob = null
showDownloadDialog = false
downloadError = null
},
) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
}
},
)
}

View File

@@ -3,8 +3,10 @@ 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
@@ -12,19 +14,26 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.jeziellago.compose.markdowntext.MarkdownText
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.update.UpdateInfo
import org.kodein.emoji.Emoji
import org.kodein.emoji.EmojiTemplateCatalog
import org.kodein.emoji.all
@Composable
fun UpdateAvailableDialog(
updateInfo: UpdateInfo,
onDismiss: () -> Unit,
onUpdate: () -> Unit,
) {
val context = LocalContext.current
val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -37,11 +46,14 @@ fun UpdateAvailableDialog(
text = stringResource(R.string.new_version_available, updateInfo.versionName),
style = MaterialTheme.typography.bodyMedium,
)
if (!updateInfo.releaseNotes.isNullOrBlank()) {
val processedNotes = remember(updateInfo.releaseNotes) {
emojiCatalog.replaceShortcodes(updateInfo.releaseNotes)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = updateInfo.releaseNotes.take(500) +
if (updateInfo.releaseNotes.length > 500) "..." else "",
MarkdownText(
markdown = processedNotes,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -51,17 +63,26 @@ fun UpdateAvailableDialog(
confirmButton = {
TextButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
context.startActivity(intent)
onDismiss()
onUpdate()
},
) {
Text(stringResource(R.string.update))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
Row {
TextButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl))
context.startActivity(intent)
onDismiss()
}) {
Text(stringResource(R.string.view_release))
}
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
},
)

View File

@@ -2,6 +2,9 @@ package io.nekohasekai.sfa.compose.screen.settings
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings as AndroidSettings
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -12,15 +15,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AdminPanelSettings
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.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SystemUpdateAlt
import androidx.compose.material3.Switch
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
@@ -36,11 +43,14 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -52,11 +62,13 @@ import androidx.navigation.NavController
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog
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.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -73,6 +85,38 @@ fun AppSettingsScreen(navController: NavController) {
var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) }
var showErrorDialog by remember { mutableStateOf<Int?>(null) }
var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) }
var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) }
var isMethodAvailable by remember { mutableStateOf(true) }
var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) }
var showInstallMethodMenu by remember { mutableStateOf(false) }
var isVerifyingMethod by remember { mutableStateOf(false) }
var silentInstallError by remember { mutableStateOf<String?>(null) }
var showDownloadDialog by remember { mutableStateOf(false) }
var downloadJob by remember { mutableStateOf<Job?>(null) }
var downloadError by remember { mutableStateOf<String?>(null) }
var showUpdateAvailableDialog by remember { mutableStateOf(false) }
// Re-check method availability when returning from background (e.g., after granting permission)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (silentInstallEnabled) {
scope.launch {
val success = withContext(Dispatchers.IO) {
Vendor.verifySilentInstallMethod(silentInstallMethod)
}
isMethodAvailable = success
silentInstallError = if (success) {
null
} else when (silentInstallMethod) {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
}
}
}
}
if (showTrackDialog) {
UpdateTrackDialog(
currentTrack = currentTrack,
@@ -100,6 +144,94 @@ fun AppSettingsScreen(navController: NavController) {
)
}
if (showDownloadDialog) {
AlertDialog(
onDismissRequest = {},
title = { Text(stringResource(R.string.update)) },
text = {
Column {
if (downloadError != null) {
Text(
downloadError!!,
color = MaterialTheme.colorScheme.error,
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.downloading))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
downloadJob?.cancel()
downloadJob = null
showDownloadDialog = false
downloadError = null
},
) {
Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel))
}
},
)
}
if (showInstallMethodMenu) {
InstallMethodDialog(
currentMethod = silentInstallMethod,
onMethodSelected = { method ->
showInstallMethodMenu = false
if (silentInstallMethod == method) return@InstallMethodDialog
silentInstallMethod = method
Settings.silentInstallMethod = method
isVerifyingMethod = true
scope.launch {
val success = withContext(Dispatchers.IO) {
Vendor.verifySilentInstallMethod(method)
}
isVerifyingMethod = false
isMethodAvailable = success
silentInstallError = if (success) {
null
} else when (method) {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
else -> context.getString(R.string.silent_install_verify_failed, method)
}
}
},
onDismiss = { showInstallMethodMenu = false },
)
}
if (showUpdateAvailableDialog && updateInfo != null) {
UpdateAvailableDialog(
updateInfo = updateInfo!!,
onDismiss = { showUpdateAvailableDialog = false },
onUpdate = {
showDownloadDialog = true
downloadError = null
downloadJob = scope.launch {
try {
val result = withContext(Dispatchers.IO) {
Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl)
}
if (result.isFailure) {
downloadError = result.exceptionOrNull()?.message
} else {
showDownloadDialog = false
}
} catch (e: Exception) {
downloadError = e.message
}
}
},
)
}
Column(
modifier =
Modifier
@@ -222,6 +354,254 @@ fun AppSettingsScreen(navController: NavController) {
}
}
// Silent Install Section (Other flavor only)
if (Vendor.supportsSilentInstall()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.silent_install_title),
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 {
// Silent Install toggle
ListItem(
headlineContent = {
Text(
stringResource(R.string.silent_install),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
silentInstallError ?: stringResource(R.string.silent_install_description),
style = MaterialTheme.typography.bodySmall,
color = if (silentInstallError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.AdminPanelSettings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
if (isVerifyingMethod) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Switch(
checked = silentInstallEnabled,
onCheckedChange = { checked ->
silentInstallEnabled = checked
Settings.silentInstallEnabled = checked
if (checked) {
isVerifyingMethod = true
scope.launch {
val success = withContext(Dispatchers.IO) {
Vendor.verifySilentInstallMethod(silentInstallMethod)
}
isVerifyingMethod = false
isMethodAvailable = success
silentInstallError = if (success) {
null
} else when (silentInstallMethod) {
"PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available)
"SHIZUKU" -> context.getString(R.string.shizuku_not_available)
else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod)
}
}
} else {
silentInstallError = null
}
},
)
}
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
// Install Method row (when enabled)
if (silentInstallEnabled) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.silent_install_method),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
when (silentInstallMethod) {
"PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer)
"SHIZUKU" -> stringResource(R.string.install_method_shizuku)
"ROOT" -> stringResource(R.string.install_method_root)
else -> silentInstallMethod
},
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clickable { showInstallMethodMenu = true },
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
// Get Shizuku row (when Shizuku is selected but not available)
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.get_shizuku),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
stringResource(R.string.shizuku_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
context.startActivity(intent)
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
// Grant Install Permission row (when PackageInstaller is selected but permission not granted)
if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.grant_install_permission),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
stringResource(R.string.grant_install_permission_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
modifier =
Modifier
.clickable {
val intent = Intent(
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
)
context.startActivity(intent)
},
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
// Auto Update toggle
if (Vendor.supportsAutoUpdate()) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.auto_update),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
stringResource(R.string.auto_update_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.SystemUpdateAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Switch(
checked = autoUpdateEnabled,
onCheckedChange = { checked ->
autoUpdateEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.autoUpdateEnabled = checked
Vendor.scheduleAutoUpdate()
}
},
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Action Section
@@ -275,21 +655,25 @@ fun AppSettingsScreen(navController: NavController) {
},
)
.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
if (hasUpdate && updateInfo != null) {
showUpdateAvailableDialog = true
} else {
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) {
}
} catch (_: UpdateCheckException.TrackNotSupported) {
showErrorDialog = R.string.update_track_not_supported
} catch (_: Exception) {
}
UpdateState.isChecking.value = false
}
UpdateState.isChecking.value = false
}
},
colors =
@@ -323,11 +707,7 @@ fun AppSettingsScreen(navController: NavController) {
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(updateInfo!!.releaseUrl),
)
context.startActivity(intent)
showUpdateAvailableDialog = true
},
colors =
ListItemDefaults.colors(
@@ -386,3 +766,53 @@ private fun UpdateTrackDialog(
},
)
}
@Composable
private fun InstallMethodDialog(
currentMethod: String,
onMethodSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
val methods = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add("PACKAGE_INSTALLER" to stringResource(R.string.install_method_package_installer))
}
add("SHIZUKU" to stringResource(R.string.install_method_shizuku))
add("ROOT" to stringResource(R.string.install_method_root))
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.silent_install_method)) },
text = {
Column {
methods.forEach { (value, label) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onMethodSelected(value) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentMethod == value,
onClick = { onMethodSelected(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

@@ -1,5 +1,10 @@
package io.nekohasekai.sfa.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
@@ -7,4 +12,13 @@ data class UpdateInfo(
val releaseUrl: String,
val releaseNotes: String?,
val isPrerelease: Boolean,
)
val fileSize: Long = 0,
) {
fun toJson(): String = Json.encodeToString(this)
companion object {
fun fromJson(json: String): UpdateInfo? = runCatching {
Json.decodeFromString<UpdateInfo>(json)
}.getOrNull()
}
}

View File

@@ -1,19 +1,90 @@
package io.nekohasekai.sfa.update
import androidx.compose.runtime.mutableStateOf
import io.nekohasekai.sfa.BuildConfig
import io.nekohasekai.sfa.database.Settings
import java.io.File
object UpdateState {
val hasUpdate = mutableStateOf(false)
val updateInfo = mutableStateOf<UpdateInfo?>(null)
val isChecking = mutableStateOf(false)
val isDownloading = mutableStateOf(false)
val downloadError = mutableStateOf<String?>(null)
val cachedApkFile = mutableStateOf<File?>(null)
sealed class InstallStatus {
data object Idle : InstallStatus()
data object Installing : InstallStatus()
data object Success : InstallStatus()
data class Failed(val error: String) : InstallStatus()
}
val installStatus = mutableStateOf<InstallStatus>(InstallStatus.Idle)
fun setUpdate(info: UpdateInfo?) {
updateInfo.value = info
hasUpdate.value = info != null
saveToCache(info)
}
fun setInstallStatus(status: InstallStatus) {
installStatus.value = status
}
fun clear() {
hasUpdate.value = false
updateInfo.value = null
isDownloading.value = false
downloadError.value = null
installStatus.value = InstallStatus.Idle
cachedApkFile.value = null
clearCache()
}
fun resetDownload() {
isDownloading.value = false
downloadError.value = null
}
fun loadFromCache() {
val json = Settings.cachedUpdateInfo
if (json.isBlank()) return
val info = UpdateInfo.fromJson(json) ?: return
if (info.versionCode <= BuildConfig.VERSION_CODE) {
clearCache()
return
}
updateInfo.value = info
hasUpdate.value = true
val apkPath = Settings.cachedApkPath
if (apkPath.isNotBlank()) {
val apkFile = File(apkPath)
if (apkFile.exists() && apkFile.length() > 0) {
cachedApkFile.value = apkFile
} else {
Settings.cachedApkPath = ""
}
}
}
private fun saveToCache(info: UpdateInfo?) {
Settings.cachedUpdateInfo = info?.toJson() ?: ""
}
fun saveApkPath(file: File) {
Settings.cachedApkPath = file.absolutePath
cachedApkFile.value = file
}
private fun clearCache() {
Settings.cachedUpdateInfo = ""
Settings.cachedApkPath = ""
Settings.lastShownUpdateVersion = 0
}
}

View File

@@ -32,4 +32,37 @@ interface VendorInterface {
* @return UpdateInfo if update is available, null otherwise
*/
fun checkUpdateAsync(): UpdateInfo? = null
/**
* Check if silent install feature is available
* @return true if silent install is supported (Other flavor only)
*/
fun supportsSilentInstall(): Boolean = false
/**
* Check if auto update feature is available
* @return true if auto update is supported (Other flavor only)
*/
fun supportsAutoUpdate(): Boolean = false
/**
* Schedule auto update worker
*/
fun scheduleAutoUpdate() {}
/**
* Verify if the specified silent install method is available
* @param method The install method (SHIZUKU or ROOT)
* @return true if the method is available and working
*/
suspend fun verifySilentInstallMethod(method: String): Boolean = false
/**
* Download and install an APK update
* @param context The context
* @param downloadUrl The URL to download the APK from
* @return Result indicating success or failure
*/
suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> =
Result.failure(UnsupportedOperationException("Not supported in this flavor"))
}