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

@@ -104,6 +104,7 @@ dependencies {
implementation(fileTree("libs"))
implementation "androidx.core:core-ktx:1.16.0"
implementation 'androidx.compose.ui:ui'
implementation "androidx.appcompat:appcompat:1.7.1"
implementation "com.google.android.material:material:1.12.0"
implementation "androidx.constraintlayout:constraintlayout:2.2.1"
@@ -135,11 +136,17 @@ dependencies {
playImplementation "com.google.android.play:app-update-ktx:2.1.0"
playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1"
// Shizuku for silent install (other flavor only)
otherImplementation 'dev.rikka.shizuku:api:13.1.5'
otherImplementation 'dev.rikka.shizuku:provider:13.1.5'
otherImplementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
// Compose dependencies
def composeBom = platform('androidx.compose:compose-bom:2024.09.00')
def composeBom = platform('androidx.compose:compose-bom:2025.01.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
@@ -151,6 +158,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2"
implementation "androidx.compose.runtime:runtime-livedata"
implementation "sh.calvin.reorderable:reorderable:2.3.3"
implementation "com.github.jeziellago:compose-markdown:0.5.4"
implementation "org.kodein.emoji:emoji-kt:2.3.0"
}

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,18 +63,27 @@ 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 = {
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,6 +655,9 @@ fun AppSettingsScreen(navController: NavController) {
},
)
.clickable(enabled = !isChecking) {
if (hasUpdate && updateInfo != null) {
showUpdateAvailableDialog = true
} else {
scope.launch {
UpdateState.isChecking.value = true
withContext(Dispatchers.IO) {
@@ -291,6 +674,7 @@ fun AppSettingsScreen(navController: NavController) {
}
UpdateState.isChecking.value = false
}
}
},
colors =
ListItemDefaults.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"))
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<uses-sdk tools:overrideLibrary="rikka.shizuku.provider,rikka.shizuku.api,rikka.shizuku.aidl,rikka.shizuku.shared" />
<application>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<receiver
android:name=".vendor.InstallResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="io.nekohasekai.sfa.INSTALL_COMPLETE" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,9 @@
package android.content;
import android.os.Bundle;
import android.os.IInterface;
public interface IIntentReceiver extends IInterface {
void performReceive(Intent intent, int resultCode, String data, Bundle extras,
boolean ordered, boolean sticky, int sendingUser);
}

View File

@@ -0,0 +1,23 @@
package android.content;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.os.IInterface;
public interface IIntentSender extends IInterface {
void send(int code, Intent intent, String resolvedType, IBinder whitelistToken,
IIntentReceiver finishedReceiver, String requiredPermission, Bundle options);
abstract class Stub extends Binder implements IIntentSender {
public static IIntentSender asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
@Override
public IBinder asBinder() {
return this;
}
}
}

View File

@@ -0,0 +1,21 @@
package android.content.pm;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
import android.os.RemoteException;
public interface IPackageInstaller extends IInterface {
int createSession(PackageInstaller.SessionParams params, String installerPackageName, String installerAttributionTag, int userId) throws RemoteException;
IPackageInstallerSession openSession(int sessionId) throws RemoteException;
void abandonSession(int sessionId) throws RemoteException;
abstract class Stub extends Binder implements IPackageInstaller {
public static IPackageInstaller asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}

View File

@@ -0,0 +1,14 @@
package android.content.pm;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
public interface IPackageInstallerSession extends IInterface {
abstract class Stub extends Binder implements IPackageInstallerSession {
public static IPackageInstallerSession asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}

View File

@@ -0,0 +1,43 @@
package io.nekohasekai.sfa.vendor
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.utils.HTTPClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
class ApkDownloader : Closeable {
private val client = Libbox.newHTTPClient().apply {
modernTLS()
keepAlive()
}
suspend fun download(url: String): File = withContext(Dispatchers.IO) {
val cacheDir = File(Application.application.cacheDir, "updates")
cacheDir.mkdirs()
val apkFile = File(cacheDir, "update.apk")
if (apkFile.exists()) apkFile.delete()
val request = client.newRequest()
request.setUserAgent(HTTPClient.userAgent)
request.setURL(url)
val response = request.execute()
response.writeTo(apkFile.absolutePath)
if (!apkFile.exists() || apkFile.length() == 0L) {
throw Exception("Download failed: empty file")
}
UpdateState.saveApkPath(apkFile)
apkFile
}
override fun close() {
client.close()
}
}

View File

@@ -0,0 +1,84 @@
package io.nekohasekai.sfa.vendor
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import io.nekohasekai.sfa.database.Settings
import java.io.File
import java.io.FileInputStream
enum class InstallMethod {
PACKAGE_INSTALLER,
SHIZUKU,
ROOT,
}
object ApkInstaller {
fun getConfiguredMethod(): InstallMethod {
return if (Settings.silentInstallEnabled) {
InstallMethod.valueOf(Settings.silentInstallMethod)
} else {
InstallMethod.PACKAGE_INSTALLER
}
}
suspend fun install(context: Context, apkFile: File, method: InstallMethod = getConfiguredMethod()): Result<Unit> {
return when (method) {
InstallMethod.SHIZUKU -> ShizukuInstaller.install(apkFile)
InstallMethod.ROOT -> RootInstaller.install(apkFile)
InstallMethod.PACKAGE_INSTALLER -> installWithPackageInstaller(context, apkFile)
}
}
fun canSystemSilentInstall(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
suspend fun canSilentInstall(): Boolean {
val method = getConfiguredMethod()
return when (method) {
InstallMethod.PACKAGE_INSTALLER -> canSystemSilentInstall()
InstallMethod.SHIZUKU -> ShizukuInstaller.isAvailable() && ShizukuInstaller.checkPermission()
InstallMethod.ROOT -> RootInstaller.checkAccess()
}
}
private fun installWithPackageInstaller(context: Context, apkFile: File): Result<Unit> {
return try {
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
params.setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = packageInstaller.createSession(params)
packageInstaller.openSession(sessionId).use { session ->
session.openWrite("update.apk", 0, apkFile.length()).use { outputStream ->
FileInputStream(apkFile).use { inputStream ->
inputStream.copyTo(outputStream)
}
session.fsync(outputStream)
}
val intent = Intent(context, InstallResultReceiver::class.java).apply {
action = InstallResultReceiver.ACTION_INSTALL_COMPLETE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pendingIntent.intentSender)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -50,6 +50,7 @@ class GitHubUpdateChecker : Closeable {
releaseUrl = release.htmlUrl,
releaseNotes = release.body,
isPrerelease = release.prerelease,
fileSize = apkAsset?.size ?: 0,
)
}

View File

@@ -0,0 +1,47 @@
package io.nekohasekai.sfa.vendor
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
import io.nekohasekai.sfa.update.UpdateState
class InstallResultReceiver : BroadcastReceiver() {
companion object {
const val ACTION_INSTALL_COMPLETE = "io.nekohasekai.sfa.INSTALL_COMPLETE"
private const val TAG = "InstallResultReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_INSTALL_COMPLETE) return
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Log.d(TAG, "Install result: status=$status, message=$message")
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmIntent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
confirmIntent?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.d(TAG, "Installation successful")
UpdateState.setInstallStatus(UpdateState.InstallStatus.Success)
}
else -> {
Log.e(TAG, "Installation failed: $status - $message")
UpdateState.setInstallStatus(UpdateState.InstallStatus.Failed(message ?: "Unknown error"))
}
}
}
}

View File

@@ -0,0 +1,39 @@
package io.nekohasekai.sfa.vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
object RootInstaller {
suspend fun checkAccess(): Boolean = withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c echo test")
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
false
}
}
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "pm install -r \"${apkFile.absolutePath}\""))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = reader.readText()
val exitCode = process.waitFor()
if (exitCode == 0 && output.contains("Success")) {
Result.success(Unit)
} else {
Result.failure(Exception("Installation failed: $output"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,185 @@
package io.nekohasekai.sfa.vendor
import android.content.Intent
import android.content.IntentSender
import android.content.pm.IPackageInstaller
import android.content.pm.IPackageInstallerSession
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import android.os.Process
import io.nekohasekai.sfa.vendor.hidden.IPackageManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.SystemServiceHelper
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import android.content.IIntentSender
object ShizukuInstaller {
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001
fun isAvailable(): Boolean {
return try {
Shizuku.pingBinder()
} catch (e: Exception) {
false
}
}
fun checkPermission(): Boolean {
return try {
if (Shizuku.isPreV11()) {
false
} else {
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}
} catch (e: Exception) {
false
}
}
fun requestPermission() {
if (!Shizuku.isPreV11()) {
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
}
}
private fun isRunningAsRoot(): Boolean {
return try {
Shizuku.getUid() == 0
} catch (e: Exception) {
false
}
}
private fun getPackageInstaller(): IPackageInstaller {
val packageManagerBinder = SystemServiceHelper.getSystemService("package")
val packageManager = IPackageManager.Stub.asInterface(ShizukuBinderWrapper(packageManagerBinder))
val installerBinder = packageManager.packageInstaller.asBinder()
return IPackageInstaller.Stub.asInterface(ShizukuBinderWrapper(installerBinder))
}
private fun createPackageInstaller(
installer: IPackageInstaller,
installerPackageName: String,
installerAttributionTag: String?,
userId: Int
): PackageInstaller {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return PackageInstaller::class.java
.getConstructor(
IPackageInstaller::class.java,
String::class.java,
String::class.java,
Int::class.javaPrimitiveType
)
.newInstance(installer, installerPackageName, installerAttributionTag, userId)
} else {
return PackageInstaller::class.java
.getConstructor(
IPackageInstaller::class.java,
String::class.java,
Int::class.javaPrimitiveType
)
.newInstance(installer, installerPackageName, userId)
}
}
private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session {
return PackageInstaller.Session::class.java
.getConstructor(IPackageInstallerSession::class.java)
.newInstance(session)
}
private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender {
val sender = object : IIntentSender.Stub() {
override fun send(
code: Int,
intent: Intent,
resolvedType: String?,
whitelistToken: android.os.IBinder?,
finishedReceiver: android.content.IIntentReceiver?,
requiredPermission: String?,
options: android.os.Bundle?
) {
onResult(intent)
}
}
return IntentSender::class.java
.getConstructor(IIntentSender::class.java)
.newInstance(sender)
}
suspend fun install(apkFile: File): Result<Unit> = withContext(Dispatchers.IO) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
HiddenApiBypass.addHiddenApiExemptions("")
}
val iPackageInstaller = getPackageInstaller()
val isRoot = isRunningAsRoot()
val installerPackageName = if (isRoot) "io.nekohasekai.sfa" else "com.android.shell"
val installerAttributionTag: String? = null
val userId = if (isRoot) Process.myUserHandle().hashCode() else 0
val packageInstaller = createPackageInstaller(
iPackageInstaller,
installerPackageName,
installerAttributionTag,
userId
)
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
val iSession = IPackageInstallerSession.Stub.asInterface(
ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder())
)
val session = createSession(iSession)
try {
FileInputStream(apkFile).use { inputStream ->
session.openWrite("base.apk", 0, apkFile.length()).use { outputStream ->
inputStream.copyTo(outputStream)
session.fsync(outputStream)
}
}
val resultIntent = arrayOfNulls<Intent>(1)
val latch = CountDownLatch(1)
val intentSender = createIntentSender { intent ->
resultIntent[0] = intent
latch.countDown()
}
session.commit(intentSender)
latch.await(60, TimeUnit.SECONDS)
val intent = resultIntent[0]
?: return@withContext Result.failure(Exception("Installation timed out"))
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if (status == PackageInstaller.STATUS_SUCCESS) {
Result.success(Unit)
} else {
Result.failure(Exception("Installation failed: $status - $message"))
}
} finally {
session.close()
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,97 @@
package io.nekohasekai.sfa.vendor
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.update.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack
import java.util.concurrent.TimeUnit
class UpdateWorker(
private val appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
companion object {
private const val WORK_NAME = "AutoUpdate"
private const val TAG = "UpdateWorker"
fun schedule(context: Context) {
if (!Settings.autoUpdateEnabled) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Auto update disabled, cancelled scheduled work")
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateWorker>(
24, TimeUnit.HOURS
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
Log.d(TAG, "Auto update scheduled")
}
}
override suspend fun doWork(): Result {
if (!Settings.autoUpdateEnabled) {
Log.d(TAG, "Auto update disabled, skipping")
return Result.success()
}
Log.d(TAG, "Checking for updates...")
return try {
val track = UpdateTrack.fromString(Settings.updateTrack)
val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) }
if (updateInfo == null) {
Log.d(TAG, "No update available")
return Result.success()
}
Log.d(TAG, "Update available: ${updateInfo.versionName}")
UpdateState.setUpdate(updateInfo)
if (Settings.silentInstallEnabled && ApkInstaller.canSilentInstall()) {
Log.d(TAG, "Downloading update...")
val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) }
Log.d(TAG, "Installing update...")
val result = ApkInstaller.install(appContext, apkFile)
if (result.isSuccess) {
Log.d(TAG, "Update installed successfully")
} else {
Log.e(TAG, "Update installation failed", result.exceptionOrNull())
}
} else {
Log.d(TAG, "Silent install not available, update will be shown on next app launch")
}
Result.success()
} catch (e: Exception) {
Log.e(TAG, "Auto update failed", e)
Result.retry()
}
}
}

View File

@@ -6,10 +6,12 @@ import android.net.Uri
import android.util.Log
import androidx.camera.core.ImageAnalysis
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.Application
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.UpdateState
import io.nekohasekai.sfa.update.UpdateTrack
object Vendor : VendorInterface {
@@ -107,4 +109,51 @@ object Vendor : VendorInterface {
checker.checkUpdate(track)
}
}
override fun supportsSilentInstall(): Boolean {
return true
}
override fun supportsAutoUpdate(): Boolean {
return true
}
override fun scheduleAutoUpdate() {
UpdateWorker.schedule(io.nekohasekai.sfa.Application.application)
}
override suspend fun verifySilentInstallMethod(method: String): Boolean {
return when (method) {
"PACKAGE_INSTALLER" -> {
ApkInstaller.canSystemSilentInstall() &&
Application.application.packageManager.canRequestPackageInstalls()
}
"SHIZUKU" -> {
if (!ShizukuInstaller.isAvailable()) {
return false
}
if (!ShizukuInstaller.checkPermission()) {
ShizukuInstaller.requestPermission()
return false
}
true
}
"ROOT" -> RootInstaller.checkAccess()
else -> false
}
}
override suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Result<Unit> {
return try {
val cachedApk = UpdateState.cachedApkFile.value
val apkFile = if (cachedApk != null && cachedApk.exists() && cachedApk.length() > 0) {
cachedApk
} else {
ApkDownloader().use { it.download(downloadUrl) }
}
ApkInstaller.install(context, apkFile)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,19 @@
package io.nekohasekai.sfa.vendor.hidden;
import android.os.Binder;
import android.os.IBinder;
import android.os.IInterface;
import android.os.RemoteException;
import android.content.pm.IPackageInstaller;
public interface IPackageManager extends IInterface {
IPackageInstaller getPackageInstaller() throws RemoteException;
abstract class Stub extends Binder implements IPackageManager {
public static IPackageManager asInterface(IBinder binder) {
throw new UnsupportedOperationException();
}
}
}