diff --git a/app/build.gradle b/app/build.gradle index 617a694..3ffde37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt index 39311fc..07a7fe4 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt @@ -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(null) } + var downloadError by remember { mutableStateOf(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)) + } + }, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt index bb6cbf7..deacda2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt @@ -3,8 +3,10 @@ package io.nekohasekai.sfa.compose.component import android.content.Intent import android.net.Uri import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -12,19 +14,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import dev.jeziellago.compose.markdowntext.MarkdownText import io.nekohasekai.sfa.R import io.nekohasekai.sfa.update.UpdateInfo +import org.kodein.emoji.Emoji +import org.kodein.emoji.EmojiTemplateCatalog +import org.kodein.emoji.all @Composable fun UpdateAvailableDialog( updateInfo: UpdateInfo, onDismiss: () -> Unit, + onUpdate: () -> Unit, ) { val context = LocalContext.current + val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) } AlertDialog( onDismissRequest = onDismiss, @@ -37,11 +46,14 @@ fun UpdateAvailableDialog( text = stringResource(R.string.new_version_available, updateInfo.versionName), style = MaterialTheme.typography.bodyMedium, ) + if (!updateInfo.releaseNotes.isNullOrBlank()) { + val processedNotes = remember(updateInfo.releaseNotes) { + emojiCatalog.replaceShortcodes(updateInfo.releaseNotes) + } Spacer(modifier = Modifier.height(12.dp)) - Text( - text = updateInfo.releaseNotes.take(500) + - if (updateInfo.releaseNotes.length > 500) "..." else "", + MarkdownText( + markdown = processedNotes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -51,17 +63,26 @@ fun UpdateAvailableDialog( confirmButton = { TextButton( onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) - context.startActivity(intent) onDismiss() + onUpdate() }, ) { Text(stringResource(R.string.update)) } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) + Row { + TextButton(onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) + context.startActivity(intent) + onDismiss() + }) { + Text(stringResource(R.string.view_release)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 92377ab..2579385 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -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(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(null) } + + var showDownloadDialog by remember { mutableStateOf(false) } + var downloadJob by remember { mutableStateOf(null) } + var downloadError by remember { mutableStateOf(null) } + var showUpdateAvailableDialog by remember { mutableStateOf(false) } + + // Re-check method availability when returning from background (e.g., after granting permission) + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (silentInstallEnabled) { + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(silentInstallMethod) + } + isMethodAvailable = success + silentInstallError = if (success) { + null + } else when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) + } + } + } + } + if (showTrackDialog) { UpdateTrackDialog( currentTrack = currentTrack, @@ -100,6 +144,94 @@ fun AppSettingsScreen(navController: NavController) { ) } + if (showDownloadDialog) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.update)) }, + text = { + Column { + if (downloadError != null) { + Text( + downloadError!!, + color = MaterialTheme.colorScheme.error, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.downloading)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + downloadJob?.cancel() + downloadJob = null + showDownloadDialog = false + downloadError = null + }, + ) { + Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) + } + }, + ) + } + + if (showInstallMethodMenu) { + InstallMethodDialog( + currentMethod = silentInstallMethod, + onMethodSelected = { method -> + showInstallMethodMenu = false + if (silentInstallMethod == method) return@InstallMethodDialog + silentInstallMethod = method + Settings.silentInstallMethod = method + isVerifyingMethod = true + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(method) + } + isVerifyingMethod = false + isMethodAvailable = success + silentInstallError = if (success) { + null + } else when (method) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, method) + } + } + }, + onDismiss = { showInstallMethodMenu = false }, + ) + } + + if (showUpdateAvailableDialog && updateInfo != null) { + UpdateAvailableDialog( + updateInfo = updateInfo!!, + onDismiss = { showUpdateAvailableDialog = false }, + onUpdate = { + showDownloadDialog = true + downloadError = null + downloadJob = scope.launch { + try { + val result = withContext(Dispatchers.IO) { + Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl) + } + if (result.isFailure) { + downloadError = result.exceptionOrNull()?.message + } else { + showDownloadDialog = false + } + } catch (e: Exception) { + downloadError = e.message + } + } + }, + ) + } + Column( modifier = Modifier @@ -222,6 +354,254 @@ fun AppSettingsScreen(navController: NavController) { } } + // Silent Install Section (Other flavor only) + if (Vendor.supportsSilentInstall()) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.silent_install_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + // Silent Install toggle + ListItem( + headlineContent = { + Text( + stringResource(R.string.silent_install), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + silentInstallError ?: stringResource(R.string.silent_install_description), + style = MaterialTheme.typography.bodySmall, + color = if (silentInstallError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AdminPanelSettings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (isVerifyingMethod) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Switch( + checked = silentInstallEnabled, + onCheckedChange = { checked -> + silentInstallEnabled = checked + Settings.silentInstallEnabled = checked + if (checked) { + isVerifyingMethod = true + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(silentInstallMethod) + } + isVerifyingMethod = false + isMethodAvailable = success + silentInstallError = if (success) { + null + } else when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) + } + } + } else { + silentInstallError = null + } + }, + ) + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Install Method row (when enabled) + if (silentInstallEnabled) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.silent_install_method), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer) + "SHIZUKU" -> stringResource(R.string.install_method_shizuku) + "ROOT" -> stringResource(R.string.install_method_root) + else -> silentInstallMethod + }, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { showInstallMethodMenu = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Get Shizuku row (when Shizuku is selected but not available) + if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.get_shizuku), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.shizuku_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Grant Install Permission row (when PackageInstaller is selected but permission not granted) + if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.grant_install_permission), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.grant_install_permission_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { + val intent = Intent( + AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}") + ) + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + // Auto Update toggle + if (Vendor.supportsAutoUpdate()) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.auto_update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.auto_update_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SystemUpdateAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = autoUpdateEnabled, + onCheckedChange = { checked -> + autoUpdateEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.autoUpdateEnabled = checked + Vendor.scheduleAutoUpdate() + } + }, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) // Action Section @@ -275,21 +655,25 @@ fun AppSettingsScreen(navController: NavController) { }, ) .clickable(enabled = !isChecking) { - scope.launch { - UpdateState.isChecking.value = true - withContext(Dispatchers.IO) { - try { - val result = Vendor.checkUpdateAsync() - UpdateState.setUpdate(result) - if (result == null) { - showErrorDialog = R.string.no_updates_available + if (hasUpdate && updateInfo != null) { + showUpdateAvailableDialog = true + } else { + scope.launch { + UpdateState.isChecking.value = true + withContext(Dispatchers.IO) { + try { + val result = Vendor.checkUpdateAsync() + UpdateState.setUpdate(result) + if (result == null) { + showErrorDialog = R.string.no_updates_available + } + } catch (_: UpdateCheckException.TrackNotSupported) { + showErrorDialog = R.string.update_track_not_supported + } catch (_: Exception) { } - } catch (_: UpdateCheckException.TrackNotSupported) { - showErrorDialog = R.string.update_track_not_supported - } catch (_: Exception) { } + UpdateState.isChecking.value = false } - UpdateState.isChecking.value = false } }, colors = @@ -323,11 +707,7 @@ fun AppSettingsScreen(navController: NavController) { Modifier .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse(updateInfo!!.releaseUrl), - ) - context.startActivity(intent) + showUpdateAvailableDialog = true }, colors = ListItemDefaults.colors( @@ -386,3 +766,53 @@ private fun UpdateTrackDialog( }, ) } + +@Composable +private fun InstallMethodDialog( + currentMethod: String, + onMethodSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val methods = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add("PACKAGE_INSTALLER" to stringResource(R.string.install_method_package_installer)) + } + add("SHIZUKU" to stringResource(R.string.install_method_shizuku)) + add("ROOT" to stringResource(R.string.install_method_root)) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.silent_install_method)) }, + text = { + Column { + methods.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onMethodSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentMethod == value, + onClick = { onMethodSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt index 05532ca..6e3fc49 100644 --- a/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt @@ -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(json) + }.getOrNull() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt index f20eab7..17efa3d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt +++ b/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt @@ -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(null) val isChecking = mutableStateOf(false) + val isDownloading = mutableStateOf(false) + val downloadError = mutableStateOf(null) + + val cachedApkFile = mutableStateOf(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.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 } } diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index 229ec4f..e27c4bf 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -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 = + Result.failure(UnsupportedOperationException("Not supported in this flavor")) } diff --git a/app/src/other/AndroidManifest.xml b/app/src/other/AndroidManifest.xml new file mode 100644 index 0000000..697a21d --- /dev/null +++ b/app/src/other/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/other/java/android/content/IIntentReceiver.java b/app/src/other/java/android/content/IIntentReceiver.java new file mode 100644 index 0000000..028a42a --- /dev/null +++ b/app/src/other/java/android/content/IIntentReceiver.java @@ -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); +} diff --git a/app/src/other/java/android/content/IIntentSender.java b/app/src/other/java/android/content/IIntentSender.java new file mode 100644 index 0000000..6d008f4 --- /dev/null +++ b/app/src/other/java/android/content/IIntentSender.java @@ -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; + } + } +} diff --git a/app/src/other/java/android/content/pm/IPackageInstaller.java b/app/src/other/java/android/content/pm/IPackageInstaller.java new file mode 100644 index 0000000..5805e37 --- /dev/null +++ b/app/src/other/java/android/content/pm/IPackageInstaller.java @@ -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(); + } + } +} diff --git a/app/src/other/java/android/content/pm/IPackageInstallerSession.java b/app/src/other/java/android/content/pm/IPackageInstallerSession.java new file mode 100644 index 0000000..9a726a1 --- /dev/null +++ b/app/src/other/java/android/content/pm/IPackageInstallerSession.java @@ -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(); + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt new file mode 100644 index 0000000..98ce882 --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt @@ -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() + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt new file mode 100644 index 0000000..8774a61 --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/ApkInstaller.kt @@ -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 { + 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 { + 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) + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt index 9cf8047..fa1a432 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -50,6 +50,7 @@ class GitHubUpdateChecker : Closeable { releaseUrl = release.htmlUrl, releaseNotes = release.body, isPrerelease = release.prerelease, + fileSize = apkAsset?.size ?: 0, ) } diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt new file mode 100644 index 0000000..b3a082f --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt @@ -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")) + } + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt new file mode 100644 index 0000000..9860fc0 --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/RootInstaller.kt @@ -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 = 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) + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt new file mode 100644 index 0000000..32dd75b --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/ShizukuInstaller.kt @@ -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 = 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(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) + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt new file mode 100644 index 0000000..0b5dc01 --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -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( + 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() + } + } +} diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index d00b2a5..68e340c 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -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 { + 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) + } + } } diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java b/app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java new file mode 100644 index 0000000..21eeaac --- /dev/null +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/hidden/IPackageManager.java @@ -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(); + } + } +}