Merge branch 'improvement/download-routine' into 'dev'

Fix issues around downloads & install states

See merge request AuroraOSS/AuroraStore!573
This commit is contained in:
Rahul Patel
2026-05-30 03:58:36 +05:30
12 changed files with 375 additions and 44 deletions

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = 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<DownloadInfo>
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)

View File

@@ -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),

View File

@@ -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<DownloadWorker>()
.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()

View File

@@ -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())

View File

@@ -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) {}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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<String>()
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<String, String>()
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<PlayFile>): 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<StorageManager>()!!
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()
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -149,6 +149,7 @@
<string name="download_eta_min">%1$dm %2$ds left</string>
<string name="download_eta_sec">%1$ds left</string>
<string name="download_failed">"Download failed"</string>
<string name="download_failed_storage">Not enough storage space to download this app</string>
<string name="download_force_clear_all">"Force clear all"</string>
<string name="download_metadata">"Getting metadata"</string>
<string name="download_none">"No downloads"</string>
@@ -520,6 +521,8 @@
<string name="status_queued">Queued</string>
<string name="status_unavailable">Unavailable</string>
<string name="status_verifying">Verifying</string>
<string name="status_installing">Installing</string>
<string name="status_installed">Installed</string>
<!-- UnarchivePackageReceiver -->
<string name="authentication_required_title">Authentication required</string>