From ada5a1f899c9a1e699d4c41f6e67fb5b0a3f1b43 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:19:33 +0530 Subject: [PATCH 1/5] Harden download routine against redownloads and flaky networks - enqueue() no longer resets an active/verifying download back to QUEUED, so the periodic update check and repeated taps can't trigger needless re-downloads; when files are already downloaded & verified it installs directly instead of re-running the pipeline. - DownloadWorker only purges partial files on a genuine user cancellation; system-initiated stops (lost connectivity, quota) keep partials and retry, fixing downloads appearing to restart on a flaky network. - Add NetworkType.CONNECTED constraint + exponential backoff and return Result.retry() for transient/network failures (capped) instead of always succeeding. - Validate HTTP 206 before resuming a .tmp (overwrite on 200) and drop corrupt files on verification failure so retries restart clean. --- .../store/data/helper/DownloadHelper.kt | 61 +++++++++- .../store/data/room/download/Download.kt | 7 ++ .../aurora/store/data/work/DownloadWorker.kt | 110 +++++++++++++++--- 3 files changed, 156 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 1fe336c11..be720efa1 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -2,14 +2,19 @@ package com.aurora.store.data.helper import android.content.Context import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager +import androidx.work.WorkRequest import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.store.AuroraApp +import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.DownloadDao @@ -18,6 +23,7 @@ import com.aurora.store.data.room.update.Update import com.aurora.store.data.work.DownloadWorker import com.aurora.store.util.PathUtil import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first @@ -32,7 +38,8 @@ import kotlinx.coroutines.launch */ class DownloadHelper @Inject constructor( @ApplicationContext private val context: Context, - private val downloadDao: DownloadDao + private val downloadDao: DownloadDao, + private val appInstaller: AppInstaller ) { companion object { @@ -92,7 +99,7 @@ class DownloadHelper @Inject constructor( * @param app [App] to download */ suspend fun enqueueApp(app: App) { - downloadDao.insert(Download.fromApp(app)) + enqueue(Download.fromApp(app)) } /** @@ -100,7 +107,7 @@ class DownloadHelper @Inject constructor( * @param update [Update] to download */ suspend fun enqueueUpdate(update: Update) { - downloadDao.insert(Download.fromUpdate(update)) + enqueue(Download.fromUpdate(update)) } /** @@ -108,7 +115,40 @@ class DownloadHelper @Inject constructor( * @param externalApk [ExternalApk] to download */ suspend fun enqueueStandalone(externalApk: ExternalApk) { - downloadDao.insert(Download.fromExternalApk(externalApk)) + enqueue(Download.fromExternalApk(externalApk)) + } + + /** + * Inserts a new download row, but only when a (re)download is actually needed. For an + * existing record of the same version this: + * - **installs without re-downloading** if the files are already downloaded & verified + * (e.g. the user missed the system install prompt, or the periodic update check runs + * again before a pending install completed); or + * - **skips** entirely if the download is still active (queued/purchasing/downloading/ + * verifying), so the periodic [UpdateWorker] and repeated user taps can't reset it back + * to [DownloadStatus.QUEUED] and re-download it. + * + * A genuinely newer version, or a previously failed/cancelled download whose files are + * gone, falls through and is (re)enqueued. + */ + private suspend fun enqueue(download: Download) { + val existing = getDownload(download.packageName) + if (existing != null && existing.versionCode == download.versionCode) { + if (existing.canInstall(context)) { + Log.i(TAG, "${download.packageName} already downloaded, installing directly") + runCatching { appInstaller.getPreferredInstaller().install(existing) } + .onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) } + return + } + if (existing.isActive) { + Log.i( + TAG, + "Skipping enqueue for ${download.packageName}; already ${existing.status}" + ) + return + } + } + downloadDao.insert(download) } /** @@ -197,11 +237,24 @@ class DownloadHelper @Inject constructor( .putString(PACKAGE_NAME, download.packageName) .build() + // Require connectivity so the worker doesn't spin up (or keep running) without a + // network, and back off exponentially so transient failures resume cleanly once the + // connection returns instead of hammering the server. + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val work = OneTimeWorkRequestBuilder() .addTag(DOWNLOAD_WORKER) .addTag("$PACKAGE_NAME:${download.packageName}") .addTag("$VERSION_CODE:${download.versionCode}") .addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .setInputData(inputData) .build() diff --git a/app/src/main/java/com/aurora/store/data/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt index 5a2ef3f40..634cb5b6d 100644 --- a/app/src/main/java/com/aurora/store/data/room/download/Download.kt +++ b/app/src/main/java/com/aurora/store/data/room/download/Download.kt @@ -43,6 +43,13 @@ data class Download( val isRunning get() = status in DownloadStatus.running private val isSuccessful get() = status == DownloadStatus.COMPLETED + /** + * `true` while the download is queued, purchasing, downloading or verifying, i.e. + * the pipeline is actively working on it. Unlike [isRunning] this also covers + * [DownloadStatus.VERIFYING], which sits between downloading and completion. + */ + val isActive get() = isRunning || status == DownloadStatus.VERIFYING + companion object { fun fromApp(app: App): Download = Download( app.packageName, diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index cc8319ccf..4bc4135a6 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -48,6 +48,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File import java.io.FileOutputStream +import java.net.HttpURLConnection.HTTP_PARTIAL import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException @@ -77,6 +78,10 @@ class DownloadWorker @AssistedInject constructor( companion object { private const val NOTIFICATION_ID: Int = 200 + + // Upper bound on automatic WorkManager retries for transient (network) failures + // before the download is marked as failed and left for the user to retry. + private const val MAX_DOWNLOAD_RETRIES = 5 } private lateinit var download: Download @@ -184,17 +189,21 @@ class DownloadWorker @AssistedInject constructor( download.downloadedFiles++ } } catch (exception: Exception) { - if (exception is DownloadCancelledException) { - Log.i(TAG, "Download cancelled for ${download.packageName}") - // Try to delete all downloaded files + // Only purge partial files on a genuine user/app cancellation. A stop caused + // by lost connectivity, quota or device-state must keep the partials so the + // retry resumes instead of re-downloading from scratch (this was the source of + // downloads appearing to "restart" on a flaky network). + if (exception is DownloadCancelledException && isCancelledByUser()) { + Log.i(TAG, "Download cancelled by user for ${download.packageName}") runCatching { files.forEach { deleteFile(it) } } } return onFailure(exception) } - // Report failure if download was stopped or failed - if (isStopped) return onFailure(DownloadFailedException()) + // A stop that isn't a user cancellation (e.g. connectivity constraint) should be + // retried with the partials intact rather than treated as a hard failure. + if (isStopped) return onFailure(DownloadCancelledException()) // Verify downloaded files try { @@ -202,6 +211,9 @@ class DownloadWorker @AssistedInject constructor( files.forEach { file -> require(verifyFile(file)) } } catch (exception: Exception) { Log.e(TAG, "Failed to verify ${download.packageName}", exception) + // Drop the corrupt files so the next attempt re-downloads them clean instead + // of resuming from a poisoned offset. + runCatching { files.forEach { deleteFile(it) } } return onFailure(VerificationFailedException()) } @@ -223,12 +235,58 @@ class DownloadWorker @AssistedInject constructor( } } + /** + * Whether the current stop/cancellation was initiated by the user (or the app on the + * user's behalf) rather than by the system (connectivity/quota/device-state). Uses the + * S+ [stopReason] when available and otherwise falls back to the persisted status, which + * [DownloadHelper.cancelDownload] sets to [DownloadStatus.CANCELLED] before cancelling + * the work — making this reliable below Android 12 too. + */ + private suspend fun isCancelledByUser(): Boolean { + val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) + if (isSAndAbove && stopReason in cancelReasons) return true + return runCatching { + downloadDao.getDownload(download.packageName).status == DownloadStatus.CANCELLED + }.getOrDefault(false) + } + + /** + * Transient errors worth retrying once connectivity returns. Walks the cause chain so a + * wrapped network error is still recognised. + */ + private fun isRetryable(throwable: Throwable?): Boolean = when (throwable) { + null -> false + is NoNetworkException, + is SocketException, + is SocketTimeoutException, + is UnknownHostException -> true + + else -> isRetryable(throwable.cause) + } + private suspend fun onFailure(exception: Exception): Result { return withContext(NonCancellable) { Log.i(TAG, "Job failed: ${download.packageName}", exception) - val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) - if (isSAndAbove && stopReason in cancelReasons) { + val cancelledByUser = isCancelledByUser() + + // Retry transient failures (lost connectivity, system-initiated stops) with + // backoff, keeping any partial download for resume. The network constraint on + // the work request means the retry only runs once connectivity is back. + val isSystemStop = exception is DownloadCancelledException && !cancelledByUser + if (!cancelledByUser && + (isRetryable(exception) || isSystemStop) && + runAttemptCount < MAX_DOWNLOAD_RETRIES + ) { + Log.w( + TAG, + "Transient failure for ${download.packageName}, " + + "retrying (attempt $runAttemptCount)" + ) + return@withContext Result.retry() + } + + if (cancelledByUser) { notifyStatus(DownloadStatus.CANCELLED) } else { when (exception) { @@ -301,23 +359,39 @@ class DownloadWorker @AssistedInject constructor( } try { - val tmpFileSuffix = ".tmp" - val tmpFile = File(file.absolutePath + tmpFileSuffix) - // Download as a temporary file to avoid installing corrupted files - val isNewFile = tmpFile.createNewFile() + val tmpFile = File(file.absolutePath + ".tmp") + val existingBytes = if (tmpFile.exists()) tmpFile.length() else 0L val okHttpClient = httpClient as HttpClient val headers = mutableMapOf() - - if (!isNewFile) { - Log.i(TAG, "$tmpFile has an unfinished download, resuming!") - downloadedBytes += tmpFile.length() - headers["Range"] = "bytes=${tmpFile.length()}-" + if (existingBytes > 0) { + Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!") + headers["Range"] = "bytes=$existingBytes-" } - okHttpClient.call(gFile.url, headers).body.byteStream().use { input -> - FileOutputStream(tmpFile, !isNewFile).use { + val response = okHttpClient.call(gFile.url, headers) + if (!response.isSuccessful) { + response.close() + throw DownloadFailedException() + } + + // Only resume when the server actually honored the Range request (206). If + // it replied 200 with the full body we must overwrite from the start, + // otherwise the full payload would be appended onto the existing partial and + // silently corrupt the file. + val resuming = existingBytes > 0 && response.code == HTTP_PARTIAL + if (resuming) { + downloadedBytes += existingBytes + } else if (existingBytes > 0) { + Log.w( + TAG, + "Server ignored Range for $tmpFile (code=${response.code}), restarting" + ) + } + + response.body.byteStream().use { input -> + FileOutputStream(tmpFile, resuming).use { input.copyTo(it, gFile.size).collect { info -> onProgress(info) } } } From ab9b66eb94b296fcb9eb5a51f15061ef877a6d5f Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:27:51 +0530 Subject: [PATCH 2/5] Serialize downloads and make cache cleanup install-aware - CacheWorker no longer purges the files of a download that is still in-flight or downloaded and awaiting install, so missing the system install prompt for longer than the cache window no longer forces a re-download. - observeDownloads now starts the next queued download only once nothing else is purchasing/downloading/verifying, instead of merely checking for a DOWNLOADING row. This serializes downloads so concurrent workers can't clobber the shared foreground/progress notification. --- .../store/data/helper/DownloadHelper.kt | 6 +++++- .../aurora/store/data/model/DownloadStatus.kt | 8 ++++++++ .../com/aurora/store/data/work/CacheWorker.kt | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index be720efa1..663d640e4 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -81,7 +81,11 @@ class DownloadHelper @Inject constructor( private fun observeDownloads() { downloadDao.downloads().onEach { list -> try { - if (list.none { it.status == DownloadStatus.DOWNLOADING }) { + // Serialize downloads: only start the next queued item once nothing else is + // actively purchasing/downloading/verifying. Previously this only checked for + // DOWNLOADING, so a worker in PURCHASING/VERIFYING didn't count and a second + // download could start concurrently and clobber the shared notification. + if (list.none { it.status in DownloadStatus.processing }) { list.find { it.status == DownloadStatus.QUEUED } ?.let { queuedDownload -> Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}") diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index da3dada10..36b34c089 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -16,5 +16,13 @@ enum class DownloadStatus(@StringRes val localized: Int) { companion object { val finished = listOf(FAILED, CANCELLED, COMPLETED) val running = listOf(QUEUED, PURCHASING, DOWNLOADING) + + /** + * States in which a download worker is actively occupying the (single) download + * slot — purchasing, transferring bytes or verifying. Used to serialize downloads: + * the next [QUEUED] item is only started once none of these are in progress, so + * concurrent workers can't clobber the shared foreground/progress notification. + */ + val processing = setOf(PURCHASING, DOWNLOADING, VERIFYING) } } diff --git a/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt b/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt index 076f0db54..5e6fcb60c 100644 --- a/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/CacheWorker.kt @@ -8,6 +8,8 @@ import androidx.work.ExistingPeriodicWorkPolicy.KEEP import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.util.PathUtil import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -17,12 +19,14 @@ import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.MINUTES import kotlin.time.DurationUnit import kotlin.time.toDuration +import kotlinx.coroutines.flow.first /** * A periodic worker to automatically clear the old downloads cache periodically. */ @HiltWorker class CacheWorker @AssistedInject constructor( + private val downloadDao: DownloadDao, @Assisted private val context: Context, @Assisted workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { @@ -58,6 +62,17 @@ class CacheWorker @AssistedInject constructor( override suspend fun doWork(): Result { Log.i(TAG, "Cleaning cache") + // Files for downloads that are still in-flight or downloaded & awaiting install + // must be protected from the age-based purge, otherwise a download the user hasn't + // installed yet (e.g. they missed the system prompt) would lose its files and have + // to be re-downloaded. Keyed by packageName -> versionCode. + val protectedVersions = runCatching { + downloadDao.downloads().first() + .filter { it.isActive || it.status == DownloadStatus.COMPLETED } + .map { it.packageName to it.versionCode } + .toSet() + }.getOrDefault(emptySet()) + PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir -> // Downloads Log.i(TAG, "Deleting old unused download directory: $dir") @@ -75,10 +90,14 @@ class CacheWorker @AssistedInject constructor( download.listFiles()!!.forEach { versionCode -> // 20240325 + val isProtected = (download.name to versionCode.name.toLongOrNull()) in + protectedVersions if (versionCode.listFiles().isNullOrEmpty()) { // Purge empty non-accessible directory Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}") versionCode.deleteRecursively() + } else if (isProtected) { + Log.i(TAG, "Keeping ${download.name} (${versionCode.name}); install pending") } else { versionCode.deleteIfOld() } From 7e8746dbcbebebf1a09fd40c428a9c714207bcae Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:29:49 +0530 Subject: [PATCH 3/5] Abandon staged install session when a download is cancelled Cancelling a download cancelled the worker and marked the row CANCELLED but never abandoned a PackageInstaller session that had already been staged for install, leaking it. Add IInstaller.cancelInstall (no-op default, implemented by SessionInstaller) and call it from DownloadHelper.cancelDownload. Cross-process session persistence/reconciliation (committing a session staged before a restart) is left as a follow-up; the startup session cleanup remains, and is now cheap to recover from since CacheWorker keeps the downloaded files. --- .../com/aurora/store/data/helper/DownloadHelper.kt | 2 ++ .../aurora/store/data/installer/SessionInstaller.kt | 10 ++++++++++ .../com/aurora/store/data/installer/base/IInstaller.kt | 7 +++++++ 3 files changed, 19 insertions(+) diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 663d640e4..44f8ade6e 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -162,6 +162,8 @@ class DownloadHelper @Inject constructor( suspend fun cancelDownload(packageName: String) { Log.i(TAG, "Cancelling download for $packageName") WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName") + // Abandon any session already staged for install so we don't leak it. + runCatching { appInstaller.getPreferredInstaller().cancelInstall(packageName) } downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED) } diff --git a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index f3726d7f4..413ca79e0 100644 --- a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt @@ -182,6 +182,16 @@ class SessionInstaller @Inject constructor( } } + override fun cancelInstall(packageName: String) { + val sessionSet = enqueuedSessions + .find { set -> set.any { it.packageName == packageName } } ?: return + + Log.i(TAG, "Abandoning staged session(s) for $packageName") + sessionSet.forEach { runCatching { packageInstaller.abandonSession(it.sessionId) } } + enqueuedSessions.remove(sessionSet) + removeFromInstallQueue(packageName) + } + private fun stageInstall( packageName: String, versionCode: Long, diff --git a/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt index 2c832110f..10b02f4a6 100644 --- a/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/base/IInstaller.kt @@ -26,4 +26,11 @@ interface IInstaller { fun clearQueue() fun isAlreadyQueued(packageName: String): Boolean fun removeFromInstallQueue(packageName: String) + + /** + * Abandons any staged-but-uncommitted install session for [packageName] so cancelling + * a download doesn't leak a [android.content.pm.PackageInstaller] session. Default no-op + * for installers that don't stage sessions. + */ + fun cancelInstall(packageName: String) {} } From 03638ca84ce3a07725b1a657412666bab46a337e Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:37:05 +0530 Subject: [PATCH 4/5] Track the install phase with INSTALLING/INSTALLED download states A download row previously stopped at COMPLETED and never reflected whether the app actually installed. Add INSTALLING/INSTALLED states driven by a central installer-event observer in DownloadHelper: - COMPLETED -> INSTALLING on the installer's first progress event - -> INSTALLED on success (row kept so the APK can still be exported) - a failed install reverts INSTALLING -> COMPLETED so it can be re-installed without re-downloading Consumers that branched on COMPLETED are updated (App Details state, MicroG status mapping, Downloads list icon). downloadStatus is stored as TEXT so no schema migration is needed. --- .../compose/composable/DownloadListItem.kt | 2 +- .../store/data/helper/DownloadHelper.kt | 34 +++++++++++++++++++ .../aurora/store/data/model/DownloadStatus.kt | 6 ++-- .../viewmodel/details/AppDetailsViewModel.kt | 3 +- .../viewmodel/onboarding/MicroGViewModel.kt | 4 ++- app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt index cedf13fb0..203e08af2 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt @@ -61,7 +61,7 @@ fun DownloadListItem(modifier: Modifier = Modifier, download: Download, onClick: ) }, trailing = when (download.status) { - DownloadStatus.COMPLETED -> { + DownloadStatus.COMPLETED, DownloadStatus.INSTALLED -> { { Icon( painter = painterResource(R.drawable.ic_check), diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index 44f8ade6e..b948a4e25 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -14,6 +14,7 @@ import androidx.work.WorkRequest import com.aurora.extensions.TAG import com.aurora.gplayapi.data.models.App import com.aurora.store.AuroraApp +import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download @@ -75,9 +76,42 @@ class DownloadHelper @Inject constructor( cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList()) }.invokeOnCompletion { observeDownloads() + observeInstalls() } } + /** + * Advances a download row through the installer phase so its history reflects whether the + * app actually installed, not just that the bytes finished downloading: + * - [InstallerEvent.Installing] moves a [DownloadStatus.COMPLETED] row to + * [DownloadStatus.INSTALLING]; + * - [InstallerEvent.Installed] marks it [DownloadStatus.INSTALLED] (kept so the user can + * still export the APK); + * - [InstallerEvent.Failed] reverts an in-progress install back to + * [DownloadStatus.COMPLETED] so the downloaded files can be re-installed without + * re-downloading. + */ + private fun observeInstalls() { + AuroraApp.events.installerEvent.onEach { event -> + val existing = getDownload(event.packageName) ?: return@onEach + when (event) { + is InstallerEvent.Installing -> if (existing.status == DownloadStatus.COMPLETED) { + downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLING) + } + + is InstallerEvent.Installed -> if (existing.status != DownloadStatus.INSTALLED) { + downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLED) + } + + is InstallerEvent.Failed -> if (existing.status == DownloadStatus.INSTALLING) { + downloadDao.updateStatus(event.packageName, DownloadStatus.COMPLETED) + } + + else -> {} + } + }.launchIn(AuroraApp.scope) + } + private fun observeDownloads() { downloadDao.downloads().onEach { list -> try { diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index 36b34c089..e703d221c 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -11,10 +11,12 @@ enum class DownloadStatus(@StringRes val localized: Int) { QUEUED(R.string.status_queued), UNAVAILABLE(R.string.status_unavailable), VERIFYING(R.string.status_verifying), - PURCHASING(R.string.preparing_to_install); + PURCHASING(R.string.preparing_to_install), + INSTALLING(R.string.status_installing), + INSTALLED(R.string.status_installed); companion object { - val finished = listOf(FAILED, CANCELLED, COMPLETED) + val finished = listOf(FAILED, CANCELLED, COMPLETED, INSTALLED) val running = listOf(QUEUED, PURCHASING, DOWNLOADING) /** diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index b9b7aad0a..1ac90d4a3 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -314,7 +314,8 @@ class AppDetailsViewModel @Inject constructor( DownloadStatus.VERIFYING -> AppState.Verifying - DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F) + DownloadStatus.COMPLETED, + DownloadStatus.INSTALLING -> if (isInstalled) defaultAppState else AppState.Installing(0F) else -> defaultAppState } diff --git a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt index fd4eb2ea8..fca1bcec4 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt @@ -213,7 +213,9 @@ class MicroGViewModel @Inject constructor( download == null -> InstallStatus.PENDING download.status == DownloadStatus.FAILED -> InstallStatus.FAILED download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING - download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING + download.status == DownloadStatus.INSTALLED -> InstallStatus.INSTALLED + download.status == DownloadStatus.COMPLETED || + download.status == DownloadStatus.INSTALLING -> InstallStatus.INSTALLING else -> InstallStatus.DOWNLOADING } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4e2f87ae..c95c36e05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,8 @@ Queued Unavailable Verifying + Installing + Installed Authentication required From 7d87a14c89a0a7c36b32dc691be28a9553da09fb Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sat, 30 May 2026 00:49:04 +0530 Subject: [PATCH 5/5] Harden download routine: storage checks, single verification, URL expiry - Check free space before downloading (only the not-yet-fetched bytes) and, on Android O+, use StorageManager.getAllocatableBytes/allocateBytes so the system can evict its cache; fail fast with a clear message instead of dying mid-write. SessionInstaller sets a SessionParams size hint so staging can reserve space too. - Verify each APK at most once: files already verified during the download pass are skipped in the final verification gate. Prefer SHA-256 and log SHA-1 fallback. - An expired download URL (403/410) now clears the stored file lists and retries, re-purchasing fresh URLs instead of repeatedly failing on the dead one. - Cancel promptly mid-file rather than only between files, and cancel copyTo's progress timer in a finally to avoid leaking the timer thread. - Download.canInstall now requires a real .apk on disk; DownloadStatus finished/running are Sets. --- .../java/com/aurora/extensions/InputStream.kt | 23 +++-- .../store/data/installer/SessionInstaller.kt | 10 +- .../aurora/store/data/model/DownloadStatus.kt | 4 +- .../store/data/room/download/Download.kt | 5 +- .../aurora/store/data/work/DownloadWorker.kt | 99 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 6 files changed, 124 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/aurora/extensions/InputStream.kt b/app/src/main/java/com/aurora/extensions/InputStream.kt index 4dc969a3d..7a508f2c3 100644 --- a/app/src/main/java/com/aurora/extensions/InputStream.kt +++ b/app/src/main/java/com/aurora/extensions/InputStream.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow = flow { var bytesCopied: Long = 0 val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) var lastTotalBytesRead: Long = 0 var speed: Long = 0 @@ -23,14 +22,20 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow lastTotalBytesRead = totalBytesRead } - while (bytes >= 0) { - out.write(buffer, 0, bytes) - out.flush() + // Cancel the timer even when the collector aborts mid-stream (e.g. the download is + // stopped), otherwise the timer thread would leak. + try { + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + out.flush() - bytesCopied += bytes - // Emit stream progress in percentage - emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed)) - bytes = read(buffer) + bytesCopied += bytes + // Emit stream progress in percentage + emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed)) + bytes = read(buffer) + } + } finally { + timer.cancel() } - timer.cancel() }.flowOn(Dispatchers.IO) diff --git a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index 413ca79e0..997fedebe 100644 --- a/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt +++ b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt @@ -199,7 +199,12 @@ class SessionInstaller @Inject constructor( ): Int? { val resolvedPackageName = sharedLibPkgName.ifBlank { packageName } - val sessionParams = buildSessionParams(resolvedPackageName) + // Size hint lets the system reserve space (and evict its cache) for the staged copy. + val totalSize = runCatching { + getFiles(packageName, versionCode, sharedLibPkgName).sumOf { it.length() } + }.getOrDefault(0L) + + val sessionParams = buildSessionParams(resolvedPackageName, totalSize) val sessionId = packageInstaller.createSession(sessionParams) val session = packageInstaller.openSession(sessionId) @@ -226,9 +231,10 @@ class SessionInstaller @Inject constructor( } } - private fun buildSessionParams(packageName: String): SessionParams = + private fun buildSessionParams(packageName: String, totalSize: Long = 0L): SessionParams = SessionParams(SessionParams.MODE_FULL_INSTALL).apply { setAppPackageName(packageName) + if (totalSize > 0) setSize(totalSize) setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) if (isNAndAbove) { setOriginatingUid(Process.myUid()) diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index e703d221c..f25ea7d52 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -16,8 +16,8 @@ enum class DownloadStatus(@StringRes val localized: Int) { INSTALLED(R.string.status_installed); companion object { - val finished = listOf(FAILED, CANCELLED, COMPLETED, INSTALLED) - val running = listOf(QUEUED, PURCHASING, DOWNLOADING) + val finished = setOf(FAILED, CANCELLED, COMPLETED, INSTALLED) + val running = setOf(QUEUED, PURCHASING, DOWNLOADING) /** * States in which a download worker is actively occupying the (single) download diff --git a/app/src/main/java/com/aurora/store/data/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt index 634cb5b6d..079ae8fac 100644 --- a/app/src/main/java/com/aurora/store/data/room/download/Download.kt +++ b/app/src/main/java/com/aurora/store/data/room/download/Download.kt @@ -115,7 +115,10 @@ data class Download( } fun canInstall(context: Context): Boolean { + if (!isSuccessful) return false val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode) - return isSuccessful && dir.listFiles() != null + // Require at least one actual APK on disk, not just that the directory exists — + // an empty/partially-cleaned directory must not look installable. + return dir.listFiles()?.any { it.name.endsWith(".apk") } == true } } diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index 4bc4135a6..9a972665c 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -11,6 +11,7 @@ import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.storage.StorageManager import android.util.Log import androidx.core.content.getSystemService import androidx.core.graphics.scale @@ -21,6 +22,7 @@ import androidx.work.WorkInfo.Companion.STOP_REASON_USER import androidx.work.WorkerParameters import com.aurora.extensions.TAG import com.aurora.extensions.copyTo +import com.aurora.extensions.isOAndAbove import com.aurora.extensions.isPAndAbove import com.aurora.extensions.isQAndAbove import com.aurora.extensions.isSAndAbove @@ -48,6 +50,9 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_GONE import java.net.HttpURLConnection.HTTP_PARTIAL import java.net.SocketException import java.net.SocketTimeoutException @@ -93,6 +98,10 @@ class DownloadWorker @AssistedInject constructor( private var totalProgress = 0 private var downloadedBytes = 0L + // Absolute paths of files already verified during the download pass, so the final + // verification gate doesn't hash large APKs a second time. + private val verifiedFiles = mutableSetOf() + inner class NoNetworkException : Exception(context.getString(R.string.title_no_network)) inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file)) inner class DownloadFailedException : Exception(context.getString(R.string.download_failed)) @@ -102,6 +111,11 @@ class DownloadWorker @AssistedInject constructor( inner class VerificationFailedException : Exception(context.getString(R.string.verification_failed)) + inner class ExpiredUrlException : Exception(context.getString(R.string.download_failed)) + + inner class InsufficientStorageException : + Exception(context.getString(R.string.download_failed_storage)) + override suspend fun doWork(): Result { super.doWork() @@ -178,6 +192,15 @@ class DownloadWorker @AssistedInject constructor( downloadDao.updateFiles(download.packageName, download.fileList) downloadDao.updateSharedLibs(download.packageName, download.sharedLibs) + // Fail fast (and let the system free its cache) if there isn't room for the download, + // instead of dying mid-write with a partial file. Only the not-yet-downloaded bytes + // need to fit. + try { + ensureStorageAvailable(totalBytes - downloadedBytesOnDisk(files)) + } catch (exception: Exception) { + return onFailure(exception) + } + // Download files try { for (file in files) { @@ -205,10 +228,13 @@ class DownloadWorker @AssistedInject constructor( // retried with the partials intact rather than treated as a hard failure. if (isStopped) return onFailure(DownloadCancelledException()) - // Verify downloaded files + // Verify downloaded files (skipping any already verified during the download pass) try { notifyStatus(DownloadStatus.VERIFYING) - files.forEach { file -> require(verifyFile(file)) } + files.forEach { file -> + val path = PathUtil.getLocalFile(context, file, download).absolutePath + if (path !in verifiedFiles) require(verifyFile(file)) + } } catch (exception: Exception) { Log.e(TAG, "Failed to verify ${download.packageName}", exception) // Drop the corrupt files so the next attempt re-downloads them clean instead @@ -259,7 +285,9 @@ class DownloadWorker @AssistedInject constructor( is NoNetworkException, is SocketException, is SocketTimeoutException, - is UnknownHostException -> true + is UnknownHostException, + // Expired URLs were re-purchased by clearing the file list; retrying re-fetches them. + is ExpiredUrlException -> true else -> isRetryable(throwable.cause) } @@ -355,6 +383,7 @@ class DownloadWorker @AssistedInject constructor( if (file.exists() && verifyFile(gFile)) { Log.i(TAG, "$file is already downloaded!") downloadedBytes += file.length() + verifiedFiles.add(file.absolutePath) return@withContext true } @@ -372,7 +401,20 @@ class DownloadWorker @AssistedInject constructor( val response = okHttpClient.call(gFile.url, headers) if (!response.isSuccessful) { + val code = response.code response.close() + // Play download URLs are short-lived; a 403/410 means ours expired while + // the download sat queued. Drop the stale file lists so the retry + // re-purchases fresh URLs instead of hammering the dead one. + if (code == HTTP_FORBIDDEN || code == HTTP_GONE) { + Log.w(TAG, "Download URL for ${download.packageName} expired (code=$code)") + downloadDao.updateFiles(download.packageName, emptyList()) + downloadDao.updateSharedLibs( + download.packageName, + download.sharedLibs.map { it.copy(fileList = emptyList()) } + ) + throw ExpiredUrlException() + } throw DownloadFailedException() } @@ -392,7 +434,12 @@ class DownloadWorker @AssistedInject constructor( response.body.byteStream().use { input -> FileOutputStream(tmpFile, resuming).use { - input.copyTo(it, gFile.size).collect { info -> onProgress(info) } + input.copyTo(it, gFile.size).collect { info -> + // Abort promptly mid-file when stopped, instead of only checking + // between files (a single split can be hundreds of MB). + if (isStopped) throw CancellationException("Download stopped") + onProgress(info) + } } } @@ -526,6 +573,10 @@ class DownloadWorker @AssistedInject constructor( val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256 val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256 + if (algorithm == Algorithm.SHA1) { + Log.w(TAG, "No SHA-256 for ${gFile.name}, falling back to SHA-1") + } + if (expectedSha.isBlank()) return false return withContext(Dispatchers.IO) { @@ -563,4 +614,44 @@ class DownloadWorker @AssistedInject constructor( Log.i(TAG, "Deleted Temp: $tmpFile") } } + + /** + * Bytes already present on disk (final or partial .tmp) for [files], so the storage check + * only requires room for what's still left to fetch. + */ + private fun downloadedBytesOnDisk(files: List): Long = files.sumOf { gFile -> + val file = PathUtil.getLocalFile(context, gFile, download) + val tmpFile = File(file.absolutePath + ".tmp") + when { + file.exists() -> file.length() + tmpFile.exists() -> tmpFile.length() + else -> 0L + } + } + + /** + * Ensures there's room for [requiredBytes] before downloading, throwing + * [InsufficientStorageException] otherwise. On Android O+ this also asks the system to + * evict its own cache to make space, per the storage guidelines. + */ + private fun ensureStorageAvailable(requiredBytes: Long) { + if (requiredBytes <= 0) return + + val dir = PathUtil.getDownloadDirectory(context).apply { mkdirs() } + if (isOAndAbove) { + val storageManager = context.getSystemService()!! + try { + val uuid = storageManager.getUuidForPath(dir) + if (storageManager.getAllocatableBytes(uuid) < requiredBytes) { + throw InsufficientStorageException() + } + storageManager.allocateBytes(uuid, requiredBytes) + } catch (exception: IOException) { + Log.e(TAG, "Failed to allocate space for ${download.packageName}", exception) + throw InsufficientStorageException() + } + } else if (dir.usableSpace < requiredBytes) { + throw InsufficientStorageException() + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c95c36e05..7084817f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ %1$dm %2$ds left %1$ds left "Download failed" + Not enough storage space to download this app "Force clear all" "Getting metadata" "No downloads"