DownloadWorker: Resume download when possible

* If file still exists, resume the download using range requests.
* Avoid deleting files when download fails as we can resume the download again.
* Assert for expected SHA and actual SHA to match
* Verify SHA after download is finished to avoid complicated logic

Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2024-03-22 14:40:09 +05:30
parent dcddfc74bd
commit ad64d512e5
2 changed files with 40 additions and 43 deletions

View File

@@ -0,0 +1,6 @@
package com.aurora.store.data.model
enum class Algorithm(var value: String) {
SHA1("SHA-1"),
SHA256("SHA-256")
}

View File

@@ -20,6 +20,7 @@ import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.requiresObbDir import com.aurora.extensions.requiresObbDir
import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.Algorithm
import com.aurora.store.data.model.DownloadInfo import com.aurora.store.data.model.DownloadInfo
import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.model.ProxyInfo import com.aurora.store.data.model.ProxyInfo
@@ -44,6 +45,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.net.Authenticator import java.net.Authenticator
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.PasswordAuthentication import java.net.PasswordAuthentication
@@ -55,7 +57,6 @@ import javax.net.ssl.HttpsURLConnection
import kotlin.properties.Delegates import kotlin.properties.Delegates
import com.aurora.gplayapi.data.models.File as GPlayFile import com.aurora.gplayapi.data.models.File as GPlayFile
@HiltWorker @HiltWorker
class DownloadWorker @AssistedInject constructor( class DownloadWorker @AssistedInject constructor(
private val downloadDao: DownloadDao, private val downloadDao: DownloadDao,
@@ -169,14 +170,15 @@ class DownloadWorker @AssistedInject constructor(
if (!requestList.all { File(it.filePath).exists() }) return Result.failure() if (!requestList.all { File(it.filePath).exists() }) return Result.failure()
// Mark download as completed // Mark download as completed
notifyStatus(DownloadStatus.COMPLETED)
Log.i(TAG, "Finished downloading ${download.packageName}")
onSuccess() onSuccess()
return Result.success() return Result.success()
} }
private suspend fun onSuccess() { private suspend fun onSuccess() {
withContext(NonCancellable) { withContext(NonCancellable) {
Log.i(TAG, "Finished downloading ${download.packageName}")
notifyStatus(DownloadStatus.COMPLETED)
try { try {
appInstaller.getPreferredInstaller().install(download) appInstaller.getPreferredInstaller().install(download)
} catch (exception: Exception) { } catch (exception: Exception) {
@@ -187,7 +189,7 @@ class DownloadWorker @AssistedInject constructor(
private suspend fun onFailure() { private suspend fun onFailure() {
withContext(NonCancellable) { withContext(NonCancellable) {
Log.i(TAG, "Cleaning up!") Log.i(TAG, "Failed downloading ${download.packageName}")
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP) val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
if (isSAndAbove() && stopReason in cancelReasons) { if (isSAndAbove() && stopReason in cancelReasons) {
notifyStatus(DownloadStatus.CANCELLED) notifyStatus(DownloadStatus.CANCELLED)
@@ -195,8 +197,6 @@ class DownloadWorker @AssistedInject constructor(
notifyStatus(DownloadStatus.FAILED) notifyStatus(DownloadStatus.FAILED)
} }
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode)
.deleteRecursively()
with(appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) { with(appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) {
cancel(NOTIFICATION_ID) cancel(NOTIFICATION_ID)
} }
@@ -236,46 +236,43 @@ class DownloadWorker @AssistedInject constructor(
return downloadList return downloadList
} }
@OptIn(ExperimentalStdlibApi::class)
private suspend fun downloadFile(request: Request): Result { private suspend fun downloadFile(request: Request): Result {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val requestFile = File(request.filePath) val requestFile = File(request.filePath)
val algorithm = if (request.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
val expectedSha = if (algorithm == Algorithm.SHA1) request.sha1 else request.sha256
// If file exists, no need to download again // If file exists and sha matches the request, no need to download again
if (!shouldDownload(request)) { if (requestFile.exists() && validSha(requestFile, expectedSha, algorithm)) {
Log.i(TAG, "$requestFile already exists") Log.i(TAG, "$requestFile is already downloaded!")
return@withContext Result.success() return@withContext Result.success()
} }
try { try {
requestFile.createNewFile() val isNewFile = requestFile.createNewFile()
val connection = if (proxy != null) {
val algorithm = if (request.sha256.isBlank()) "SHA-1" else "SHA-256" URL(request.url).openConnection(proxy) as HttpsURLConnection
val messageDigest = MessageDigest.getInstance(algorithm)
val inputStream = if (proxy != null) {
(URL(request.url).openConnection(proxy) as HttpsURLConnection).inputStream
} else { } else {
URL(request.url).openStream() URL(request.url).openConnection() as HttpsURLConnection
} }
DigestInputStream(inputStream, messageDigest).use { input -> if (!isNewFile) {
requestFile.outputStream().use { Log.i(TAG, "$requestFile has an unfinished download, resuming!")
downloadedBytes += requestFile.length()
connection.setRequestProperty("Range", "bytes=${requestFile.length()}-")
}
connection.inputStream.use { input ->
FileOutputStream(requestFile, !isNewFile).use {
input.copyTo(it, request.size).collectLatest { p -> onProgress(p) } input.copyTo(it, request.size).collectLatest { p -> onProgress(p) }
} }
} }
val sha = messageDigest.digest().toHexString() // Ensure downloaded file matches expected sha
if (!File(request.filePath).exists() || !(sha == request.sha1 || sha == request.sha256)) { assert(validSha(requestFile, expectedSha, algorithm))
Log.e(TAG, "$requestFile is either missing or corrupt")
notifyStatus(DownloadStatus.FAILED)
return@withContext Result.failure()
}
return@withContext Result.success() return@withContext Result.success()
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to download ${request.filePath}!", exception) Log.e(TAG, "Failed to download ${request.filePath}!", exception)
requestFile.delete()
notifyStatus(DownloadStatus.FAILED) notifyStatus(DownloadStatus.FAILED)
return@withContext Result.failure() return@withContext Result.failure()
} }
@@ -348,24 +345,18 @@ class DownloadWorker @AssistedInject constructor(
} }
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
private suspend fun shouldDownload(request: Request): Boolean { private suspend fun validSha(file: File, expectedSha: String, algorithm: Algorithm): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val file = File(request.filePath) val messageDigest = MessageDigest.getInstance(algorithm.value)
if (file.exists()) { DigestInputStream(file.inputStream(), messageDigest).use { input ->
val algorithm = if (request.sha256.isBlank()) "SHA-1" else "SHA-256" val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
val messageDigest = MessageDigest.getInstance(algorithm) var read = input.read(buffer, 0, DEFAULT_BUFFER_SIZE)
DigestInputStream(file.inputStream(), messageDigest).use { input -> while (read > -1) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) read = input.read(buffer, 0, DEFAULT_BUFFER_SIZE)
var read = input.read(buffer, 0, DEFAULT_BUFFER_SIZE)
while (read > -1) {
read = input.read(buffer, 0, DEFAULT_BUFFER_SIZE)
}
} }
val sha = messageDigest.digest().toHexString()
return@withContext !(sha == request.sha1 || sha == request.sha256)
} else {
return@withContext true
} }
val sha = messageDigest.digest().toHexString()
return@withContext sha == expectedSha
} }
} }