Improve update system
This commit is contained in:
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user