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/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 1fe336c11..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 @@ -2,14 +2,20 @@ 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.event.InstallerEvent +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 +24,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 +39,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 { @@ -68,13 +76,50 @@ 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 { - 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}") @@ -92,7 +137,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 +145,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 +153,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) } /** @@ -118,6 +196,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) } @@ -197,11 +277,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/installer/SessionInstaller.kt b/app/src/main/java/com/aurora/store/data/installer/SessionInstaller.kt index f3726d7f4..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 @@ -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, @@ -189,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) @@ -216,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/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) {} } 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..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 @@ -11,10 +11,20 @@ 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 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 + * 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/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt index 5a2ef3f40..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 @@ -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, @@ -108,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/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() } 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..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,10 @@ 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 import java.net.UnknownHostException @@ -77,6 +83,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 @@ -88,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)) @@ -97,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() @@ -173,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) { @@ -184,24 +212,34 @@ 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 + // 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 + // of resuming from a poisoned offset. + runCatching { files.forEach { deleteFile(it) } } return onFailure(VerificationFailedException()) } @@ -223,12 +261,60 @@ 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, + // Expired URLs were re-purchased by clearing the file list; retrying re-fetches them. + is ExpiredUrlException -> 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) { @@ -297,28 +383,63 @@ 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 } 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 { - input.copyTo(it, gFile.size).collect { info -> onProgress(info) } + 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() + } + + // 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 -> + // 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) + } } } @@ -452,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) { @@ -489,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/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..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" @@ -520,6 +521,8 @@ Queued Unavailable Verifying + Installing + Installed Authentication required