From 26fbf9561668c16b37f67c3c9bb6ba10287e096c Mon Sep 17 00:00:00 2001 From: codeiva11 Date: Sat, 26 Sep 2026 11:21:04 +0530 Subject: [PATCH 1/2] 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 From b95fb41ffb5f9ed8f4cf810c19142228153d0ed8 Mon Sep 17 00:00:00 2001 From: codeiva11 Date: Mon, 5 Oct 2026 14:45:26 +0530 Subject: [PATCH 2/2] fix: address review feedback - delete existing target before rename, group installParams, extract shared installer method --- .../cloudstream3/ui/settings/ApkUpdater.kt | 88 +++++++------------ .../ui/settings/GithubViewModel.kt | 7 +- 2 files changed, 37 insertions(+), 58 deletions(-) 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 d26fdcc6f57..7d50f7d73f8 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 @@ -6,7 +6,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller -import android.os.Build import androidx.core.content.FileProvider import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity @@ -93,6 +92,9 @@ object ApkUpdater : AppUpdater { } } + if (targetFile.exists()) { + targetFile.delete() + } if (tempFile.renameTo(targetFile)) { targetFile } else { @@ -111,49 +113,14 @@ object ApkUpdater : AppUpdater { 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) - } + 0 -> installWithPackageInstaller( + activity = activity, + readStream = file.inputStream(), + length = file.length(), + digest = null, + downloadProgress = installProgress + ) + else -> openApk(activity, file) } } @@ -175,19 +142,30 @@ object ApkUpdater : AppUpdater { length: Long?, digest: DigestPair?, downloadProgress: (Long, Long?) -> Unit, + ) = withContext(Dispatchers.IO) { + installWithPackageInstaller(activity, readStream, length, digest, downloadProgress) + } + + @Throws + private suspend fun installWithPackageInstaller( + activity: Activity, + readStream: InputStream, + length: Long?, + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit, ) = withContext(Dispatchers.IO) { 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) - } - if (length != null) { - installParams.setSize(length) - } + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + if (length != null) { + setSize(length) + } + } sessionId = packageInstaller.createSession(installParams) val session = packageInstaller.openSession(sessionId) @@ -200,7 +178,7 @@ object ApkUpdater : AppUpdater { } val receiverIntent = Intent(activity, PackageInstallerStatusReceiver::class.java) - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT } else { PendingIntent.FLAG_UPDATE_CURRENT @@ -212,8 +190,8 @@ object ApkUpdater : AppUpdater { session.commit(receiverPendingIntent.intentSender) session.close() } catch (t: Throwable) { - sessionId?.let { sessionId -> - packageInstaller.abandonSession(sessionId) + sessionId?.let { id -> + packageInstaller.abandonSession(id) } throw t } 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 1aead52df46..b559366b13b 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 @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.settings import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream4.AppSettings @@ -197,7 +198,7 @@ class GithubViewModel( } private suspend fun installUpdate(file: GithubReleases.GithubFile) = dispatchUpdate { - val activity = com.lagradost.cloudstream3.CommonActivity.activity + val activity = CommonActivity.activity val cachedFile = activity?.let { ApkUpdater.getCachedUpdateFile(it, file.tagName) } @@ -301,7 +302,7 @@ class GithubViewModel( // If automated background search, download the update APK silently in advance if (!fromUser) { - val activity = com.lagradost.cloudstream3.CommonActivity.activity + val activity = CommonActivity.activity if (activity != null) { ApkUpdater.downloadSilently( activity, @@ -344,7 +345,7 @@ class GithubViewModel( ) private fun deleteCachedApk(tagName: String) { - val activity = com.lagradost.cloudstream3.CommonActivity.activity + val activity = CommonActivity.activity if (activity != null) { val file = ApkUpdater.getCachedUpdateFile(activity, tagName) if (file.exists()) {