From 26fbf9561668c16b37f67c3c9bb6ba10287e096c Mon Sep 17 00:00:00 2001 From: codeiva11 Date: Sat, 26 Sep 2026 11:21:04 +0530 Subject: [PATCH] feat(updater): silent background pre-download and instant install for Compose updater --- .../cloudstream3/MainActivityScreen.kt | 42 +++++++- .../cloudstream3/ui/settings/ApkUpdater.kt | 96 ++++++++++++++++++- .../ui/settings/GithubViewModel.kt | 76 +++++++++++++-- app/src/main/res/values/strings.xml | 1 + 4 files changed, 200 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt index 28553b3200e..75e6f70250f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivityScreen.kt @@ -61,9 +61,9 @@ object MainActivityScreen : Screen { buildSha = activity.currentCommitHash(), settings = settings, updater = ApkUpdater - )/*.apply { - onAction(AutoSearchForUpdate) // Remove this for now - }*/ + ).apply { + onAction(AutoSearchForUpdate) + } } } @@ -171,6 +171,42 @@ object MainActivityScreen : Screen { } } + is GithubUpdateDialogState.InstallProgress -> { + cancelable = false + title = stringResource(R.string.install_update) + body = { + val progress = if (state.total != null && state.total > 0L) { + (state.progress.toFloat() / state.total.toFloat()).coerceIn(0.0f, 1.0f) + } else null + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (progress != null) { + Text( + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + text = "${(progress * 100.0f).toInt()}%" + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + progress = { progress } + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + confirmButton = {} + dismissButton = {} + } + is GithubUpdateDialogState.DownloadProgress -> { cancelable = false title = stringResource(R.string.update_notification_downloading) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt index 08e279b49a0..d26fdcc6f57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -65,9 +65,101 @@ object ApkUpdater : AppUpdater { } } - fun clearOldFiles(activity: Activity) { + fun getCachedUpdateFile(context: Context, versionTag: String): File { + return File(context.cacheDir, "${APP_UPDATE_NAME}_$versionTag.$APP_UPDATE_SUFFIX") + } + + suspend fun downloadSilently( + context: Context, + url: String, + versionTag: String, + digest: DigestPair? + ): File? = withContext(Dispatchers.IO) { + try { + val targetFile = getCachedUpdateFile(context, versionTag) + if (targetFile.exists() && targetFile.length() > 0) { + return@withContext targetFile + } + + clearOldFiles(context) + val request = app.get(url) + val length = request.size + val body = request.body + val tempFile = File.createTempFile(APP_UPDATE_NAME, ".$APP_UPDATE_SUFFIX", context.cacheDir) + body.use { body -> + val readStream = body.byteStream() + tempFile.outputStream().use { writeStream -> + transfer(writeStream, readStream, length ?: body.contentLength(), { _, _ -> }, digest) + } + } + + if (tempFile.renameTo(targetFile)) { + targetFile + } else { + tempFile + } + } catch (_: Throwable) { + null + } + } + + @Throws + suspend fun installFromFile( + activity: Activity, + file: File, + settings: AppSettings, + installProgress: (Long, Long?) -> Unit + ) = withContext(Dispatchers.IO) { + when (settings.updates.apkInstaller.get()) { + 0 -> { + val length = file.length() + file.inputStream().use { inputStream -> + var sessionId: Int? = null + val packageInstaller = activity.packageManager.packageInstaller + try { + val installParams = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + installParams.setSize(length) + + sessionId = packageInstaller.createSession(installParams) + val session = packageInstaller.openSession(sessionId) + + session.openWrite(activity.packageName, 0, length) + .use { writeStream -> + transfer(writeStream, inputStream, length, installProgress, null) + session.fsync(writeStream) + } + + val receiverIntent = Intent(activity, PackageInstallerStatusReceiver::class.java) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val receiverPendingIntent = PendingIntent.getBroadcast(activity, 0, receiverIntent, flags) + session.commit(receiverPendingIntent.intentSender) + session.close() + } catch (t: Throwable) { + sessionId?.let { id -> + packageInstaller.abandonSession(id) + } + throw t + } + } + } + else -> { + openApk(activity, file) + } + } + } + + fun clearOldFiles(context: Context) { // Delete old files - activity.cacheDir.listFiles()?.filter { + context.cacheDir.listFiles()?.filter { it.name.startsWith(APP_UPDATE_NAME) && it.extension == APP_UPDATE_SUFFIX }?.forEach { deleteFileOnExit(it) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt index 99236ab7173..1aead52df46 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt @@ -24,6 +24,7 @@ data class GithubState( sealed class GithubUpdateDialogState { data class Error(val error: Throwable) : GithubUpdateDialogState() data class DownloadProgress(val progress: Long, val total: Long?) : GithubUpdateDialogState() + data class InstallProgress(val progress: Long, val total: Long?) : GithubUpdateDialogState() object Loading : GithubUpdateDialogState() object NoUpdateFound : GithubUpdateDialogState() data class UpdateFound( @@ -153,6 +154,7 @@ class GithubViewModel( is GithubAction.SkipThisUpdate -> { settings.updates.skipUpdate.set(action.file.nodeId) + deleteCachedApk(action.file.tagName) } GithubAction.AutoSearchForUpdate -> { @@ -165,11 +167,12 @@ class GithubViewModel( is GithubAction.SkipUpdate -> { settings.updates.skipUpdate.set(action.file.nodeId) + deleteCachedApk(action.file.tagName) } is GithubAction.Update -> { ioSafe { - installUpdate(action.file.downloadUrl, action.file.digest) + installUpdate(action.file) } } } @@ -193,22 +196,52 @@ class GithubViewModel( } } - private suspend fun installUpdate(url: String, digestPair: String?) = dispatchUpdate { - updater.update( - settings = settings, - url = url, - digest = DigestPair.parse(digestPair), - ) { progress, total -> + private suspend fun installUpdate(file: GithubReleases.GithubFile) = dispatchUpdate { + val activity = com.lagradost.cloudstream3.CommonActivity.activity + val cachedFile = activity?.let { + ApkUpdater.getCachedUpdateFile(it, file.tagName) + } + + if (activity != null && cachedFile != null && cachedFile.exists() && cachedFile.length() > 0) { updateState { copy( dialog = dialog?.copy( - state = GithubUpdateDialogState.DownloadProgress( - progress = progress, - total = total + state = GithubUpdateDialogState.InstallProgress( + progress = 0, + total = cachedFile.length() ) ) ) } + ApkUpdater.installFromFile(activity, cachedFile, settings) { progress, total -> + updateState { + copy( + dialog = dialog?.copy( + state = GithubUpdateDialogState.InstallProgress( + progress = progress, + total = total + ) + ) + ) + } + } + } else { + updater.update( + settings = settings, + url = file.downloadUrl, + digest = DigestPair.parse(file.digest), + ) { progress, total -> + updateState { + copy( + dialog = dialog?.copy( + state = GithubUpdateDialogState.DownloadProgress( + progress = progress, + total = total + ) + ) + ) + } + } } updateState { copy( @@ -266,6 +299,19 @@ class GithubViewModel( return@dispatchUpdate } + // If automated background search, download the update APK silently in advance + if (!fromUser) { + val activity = com.lagradost.cloudstream3.CommonActivity.activity + if (activity != null) { + ApkUpdater.downloadSilently( + activity, + release.downloadUrl, + release.tagName, + DigestPair.parse(release.digest) + ) + } + } + updateState { copy( dialog = baseDialog.copy( @@ -296,4 +342,14 @@ class GithubViewModel( userName = remoteUserName, repository = remoteRepository, ) + + private fun deleteCachedApk(tagName: String) { + val activity = com.lagradost.cloudstream3.CommonActivity.activity + if (activity != null) { + val file = ApkUpdater.getCachedUpdateFile(activity, tagName) + if (file.exists()) { + file.delete() + } + } + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb3533c4c..f7197f3871f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -593,6 +593,7 @@ Downloading app update… Installing app update… Could not install the new version of the app + Installing update… Legacy PackageInstaller App will be updated upon exit