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:
6
app/src/main/java/com/aurora/store/data/model/SHA.kt
Normal file
6
app/src/main/java/com/aurora/store/data/model/SHA.kt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package com.aurora.store.data.model
|
||||||
|
|
||||||
|
enum class Algorithm(var value: String) {
|
||||||
|
SHA1("SHA-1"),
|
||||||
|
SHA256("SHA-256")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user