Improve update system
This commit is contained in:
@@ -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"
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
29
app/src/other/AndroidManifest.xml
Normal file
29
app/src/other/AndroidManifest.xml
Normal 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>
|
||||
9
app/src/other/java/android/content/IIntentReceiver.java
Normal file
9
app/src/other/java/android/content/IIntentReceiver.java
Normal 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);
|
||||
}
|
||||
23
app/src/other/java/android/content/IIntentSender.java
Normal file
23
app/src/other/java/android/content/IIntentSender.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/src/other/java/android/content/pm/IPackageInstaller.java
Normal file
21
app/src/other/java/android/content/pm/IPackageInstaller.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal file
43
app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
84
app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt
vendored
Normal file
84
app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class GitHubUpdateChecker : Closeable {
|
||||
releaseUrl = release.htmlUrl,
|
||||
releaseNotes = release.body,
|
||||
isPrerelease = release.prerelease,
|
||||
fileSize = apkAsset?.size ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
47
app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal file
47
app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt
vendored
Normal 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal file
39
app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt
vendored
Normal file
185
app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal file
97
app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java
vendored
Normal file
19
app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user