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.
This commit is contained in:
@@ -2,14 +2,19 @@ package com.aurora.store.data.helper
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.OutOfQuotaPolicy
|
import androidx.work.OutOfQuotaPolicy
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkRequest
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
import com.aurora.store.AuroraApp
|
import com.aurora.store.AuroraApp
|
||||||
|
import com.aurora.store.data.installer.AppInstaller
|
||||||
import com.aurora.store.data.model.DownloadStatus
|
import com.aurora.store.data.model.DownloadStatus
|
||||||
import com.aurora.store.data.room.download.Download
|
import com.aurora.store.data.room.download.Download
|
||||||
import com.aurora.store.data.room.download.DownloadDao
|
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.data.work.DownloadWorker
|
||||||
import com.aurora.store.util.PathUtil
|
import com.aurora.store.util.PathUtil
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -32,7 +38,8 @@ import kotlinx.coroutines.launch
|
|||||||
*/
|
*/
|
||||||
class DownloadHelper @Inject constructor(
|
class DownloadHelper @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val downloadDao: DownloadDao
|
private val downloadDao: DownloadDao,
|
||||||
|
private val appInstaller: AppInstaller
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -92,7 +99,7 @@ class DownloadHelper @Inject constructor(
|
|||||||
* @param app [App] to download
|
* @param app [App] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueApp(app: App) {
|
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
|
* @param update [Update] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueUpdate(update: Update) {
|
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
|
* @param externalApk [ExternalApk] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueStandalone(externalApk: ExternalApk) {
|
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)
|
.putString(PACKAGE_NAME, download.packageName)
|
||||||
.build()
|
.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>()
|
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.addTag(DOWNLOAD_WORKER)
|
.addTag(DOWNLOAD_WORKER)
|
||||||
.addTag("$PACKAGE_NAME:${download.packageName}")
|
.addTag("$PACKAGE_NAME:${download.packageName}")
|
||||||
.addTag("$VERSION_CODE:${download.versionCode}")
|
.addTag("$VERSION_CODE:${download.versionCode}")
|
||||||
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
|
.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)
|
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||||
.setInputData(inputData)
|
.setInputData(inputData)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ data class Download(
|
|||||||
val isRunning get() = status in DownloadStatus.running
|
val isRunning get() = status in DownloadStatus.running
|
||||||
private val isSuccessful get() = status == DownloadStatus.COMPLETED
|
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 {
|
companion object {
|
||||||
fun fromApp(app: App): Download = Download(
|
fun fromApp(app: App): Download = Download(
|
||||||
app.packageName,
|
app.packageName,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import dagger.assisted.Assisted
|
|||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection.HTTP_PARTIAL
|
||||||
import java.net.SocketException
|
import java.net.SocketException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
@@ -77,6 +78,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val NOTIFICATION_ID: Int = 200
|
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
|
private lateinit var download: Download
|
||||||
@@ -184,17 +189,21 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
download.downloadedFiles++
|
download.downloadedFiles++
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is DownloadCancelledException) {
|
// Only purge partial files on a genuine user/app cancellation. A stop caused
|
||||||
Log.i(TAG, "Download cancelled for ${download.packageName}")
|
// by lost connectivity, quota or device-state must keep the partials so the
|
||||||
// Try to delete all downloaded files
|
// 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) } }
|
runCatching { files.forEach { deleteFile(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
return onFailure(exception)
|
return onFailure(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report failure if download was stopped or failed
|
// A stop that isn't a user cancellation (e.g. connectivity constraint) should be
|
||||||
if (isStopped) return onFailure(DownloadFailedException())
|
// retried with the partials intact rather than treated as a hard failure.
|
||||||
|
if (isStopped) return onFailure(DownloadCancelledException())
|
||||||
|
|
||||||
// Verify downloaded files
|
// Verify downloaded files
|
||||||
try {
|
try {
|
||||||
@@ -202,6 +211,9 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
files.forEach { file -> require(verifyFile(file)) }
|
files.forEach { file -> require(verifyFile(file)) }
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to verify ${download.packageName}", 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())
|
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 {
|
private suspend fun onFailure(exception: Exception): Result {
|
||||||
return withContext(NonCancellable) {
|
return withContext(NonCancellable) {
|
||||||
Log.i(TAG, "Job failed: ${download.packageName}", exception)
|
Log.i(TAG, "Job failed: ${download.packageName}", exception)
|
||||||
|
|
||||||
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
|
val cancelledByUser = isCancelledByUser()
|
||||||
if (isSAndAbove && stopReason in cancelReasons) {
|
|
||||||
|
// 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)
|
notifyStatus(DownloadStatus.CANCELLED)
|
||||||
} else {
|
} else {
|
||||||
when (exception) {
|
when (exception) {
|
||||||
@@ -301,23 +359,39 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val tmpFileSuffix = ".tmp"
|
|
||||||
val tmpFile = File(file.absolutePath + tmpFileSuffix)
|
|
||||||
|
|
||||||
// Download as a temporary file to avoid installing corrupted files
|
// 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 okHttpClient = httpClient as HttpClient
|
||||||
val headers = mutableMapOf<String, String>()
|
val headers = mutableMapOf<String, String>()
|
||||||
|
if (existingBytes > 0) {
|
||||||
if (!isNewFile) {
|
Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!")
|
||||||
Log.i(TAG, "$tmpFile has an unfinished download, resuming!")
|
headers["Range"] = "bytes=$existingBytes-"
|
||||||
downloadedBytes += tmpFile.length()
|
|
||||||
headers["Range"] = "bytes=${tmpFile.length()}-"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
okHttpClient.call(gFile.url, headers).body.byteStream().use { input ->
|
val response = okHttpClient.call(gFile.url, headers)
|
||||||
FileOutputStream(tmpFile, !isNewFile).use {
|
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) }
|
input.copyTo(it, gFile.size).collect { info -> onProgress(info) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user