DownloadWorker: Verify downloaded files using SHA256 or SHA1

This lets us avoid downloading same files again

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2023-12-28 20:45:05 +05:30
parent c99d213df3
commit 4d4429fd7a
10 changed files with 46 additions and 158 deletions

View File

@@ -3,5 +3,7 @@ package com.aurora.store.data.model
data class Request(
val url: String,
val filePath: String,
val size: Long
val size: Long,
val sha1: String,
val sha256: String
)

View File

@@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import java.io.File
import java.net.URL
import java.security.DigestInputStream
import java.security.MessageDigest
import kotlinx.coroutines.NonCancellable
import kotlin.properties.Delegates
import com.aurora.gplayapi.data.models.File as GPlayFile
@@ -191,27 +193,40 @@ class DownloadWorker @AssistedInject constructor(
PathUtil.getObbDownloadFile(download.packageName, it)
}
}
downloadList.add(Request(it.url, filePath, it.size))
downloadList.add(Request(it.url, filePath, it.size, it.sha1, it.sha256))
}
return downloadList
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun downloadFile(request: Request): Result {
return withContext(Dispatchers.IO) {
val requestFile = File(request.filePath)
// If file exists, no need to download again
if (!shouldDownload(request)) {
Log.i(TAG, "$requestFile already exists")
return@withContext Result.success()
}
try {
val algorithm = if (request.sha256.isBlank()) "SHA-1" else "SHA-256"
val messageDigest = MessageDigest.getInstance(algorithm)
requestFile.createNewFile()
URL(request.url).openStream().use { input ->
DigestInputStream(URL(request.url).openStream(), messageDigest).use { input ->
requestFile.outputStream().use {
input.copyTo(it, request.size).collectLatest { p -> onProgress(p) }
}
}
// Ensure downloaded file exists
if (!File(request.filePath).exists()) {
Log.e(TAG, "Failed to find downloaded file at ${request.filePath}")
val sha = messageDigest.digest().toHexString()
if (!File(request.filePath).exists() || !(sha == request.sha1 || sha == request.sha256)) {
Log.e(TAG, "$requestFile is either missing or corrupt")
notifyStatus(DownloadStatus.FAILED)
return@withContext Result.failure()
}
return@withContext Result.success()
} catch (exception: Exception) {
Log.e(TAG, "Failed to download ${request.filePath}!", exception)
@@ -277,4 +292,26 @@ class DownloadWorker @AssistedInject constructor(
val notificationID = if (dID != -1) dID else download.packageName.hashCode()
notificationManager.notify(notificationID, notification)
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun shouldDownload(request: Request): Boolean {
return withContext(Dispatchers.IO) {
val file = File(request.filePath)
if (file.exists()) {
val algorithm = if (request.sha256.isBlank()) "SHA-1" else "SHA-256"
val messageDigest = MessageDigest.getInstance(algorithm)
DigestInputStream(file.inputStream(), messageDigest).use { input ->
val buffer = ByteArray(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
}
}
}
}

View File

@@ -129,8 +129,4 @@ class ActionButton : RelativeLayout {
fun addOnClickListener(onClickListener: OnClickListener?) {
B.btn.setOnClickListener(onClickListener)
}
fun addOnLongClickListener(onLongClickListener: OnLongClickListener?) {
B.btn.setOnLongClickListener(onLongClickListener)
}
}

View File

@@ -25,7 +25,6 @@ import android.widget.RelativeLayout
import com.aurora.extensions.getString
import com.aurora.extensions.runOnUiThread
import com.aurora.store.R
import com.aurora.store.State
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.databinding.ViewUpdateButtonBinding
@@ -77,7 +76,6 @@ class UpdateButton : RelativeLayout {
val displayChild = when (downloadStatus) {
DownloadStatus.QUEUED,
DownloadStatus.DOWNLOADING -> 2
DownloadStatus.COMPLETED -> 3
else -> 0
}
@@ -95,8 +93,4 @@ class UpdateButton : RelativeLayout {
fun addNegativeOnClickListener(onClickListener: OnClickListener?) {
B.btnNegative.setOnClickListener(onClickListener)
}
fun addInstallOnClickListener(onClickListener: OnClickListener?) {
B.btnInstall.setOnClickListener(onClickListener)
}
}

View File

@@ -44,9 +44,7 @@ import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download
import com.aurora.store.databinding.ViewAppUpdateBinding
import com.aurora.store.util.CommonUtil
import com.aurora.store.util.PathUtil
import com.aurora.store.view.epoxy.views.BaseView
import java.io.File
@ModelView(
autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT,
@@ -135,20 +133,6 @@ class AppUpdateView : RelativeLayout {
}
}
}
DownloadStatus.COMPLETED -> {
B.progressDownload.invisible()
// It is possible that while the app was downloaded in past, files have been removed
// Check once again to reflect appropriate status
val files = File(
PathUtil.getAppDownloadDir(
context,
download.packageName,
download.versionCode
).path
).listFiles()
if (files.isNullOrEmpty()) B.btnAction.updateState(DownloadStatus.UNAVAILABLE)
}
else -> {
B.progressDownload.progress = 0
B.progressDownload.invisible()
@@ -172,11 +156,6 @@ class AppUpdateView : RelativeLayout {
B.btnAction.addNegativeOnClickListener(onClickListener)
}
@CallbackProp
fun installAction(onClickListener: OnClickListener?) {
B.btnAction.addInstallOnClickListener(onClickListener)
}
@CallbackProp
fun longClick(onClickListener: OnLongClickListener?) {
B.layoutContent.setOnLongClickListener(onClickListener)

View File

@@ -52,7 +52,6 @@ import com.aurora.extensions.isRAndAbove
import com.aurora.extensions.runOnUiThread
import com.aurora.extensions.share
import com.aurora.extensions.show
import com.aurora.extensions.showDialog
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.AuthData
@@ -96,7 +95,6 @@ import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.util.Locale
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
@@ -437,31 +435,6 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
}
}
@Synchronized
private fun install() {
val files = File(
PathUtil.getAppDownloadDir(
requireContext(),
app.packageName,
app.versionCode
).path
).listFiles() ?: return
val apkFiles = files.filter { it.path.endsWith(".apk") }
val preferredInstaller =
Preferences.getInteger(requireContext(), Preferences.PREFERENCE_INSTALLER_ID)
if (apkFiles.size > 1 && preferredInstaller == 1) {
showDialog(R.string.title_installer, R.string.dialog_desc_native_split)
} else {
viewModel.install(requireContext(), app.packageName, apkFiles)
runOnUiThread {
binding.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing))
}
}
}
@Synchronized
private fun uninstallApp() {
val installer = AppInstaller.getInstance(requireContext()).getPreferredInstaller()
@@ -554,31 +527,13 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
}
@Synchronized
private fun startDownload(forceDownload: Boolean = false) {
private fun startDownload() {
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
flip(1)
toast("Already downloading")
}
DownloadStatus.COMPLETED -> {
if (forceDownload) {
purchase()
return
}
// Check if files are still present, else proceed to re-download the app
val files = File(
PathUtil.getAppDownloadDir(
requireContext(),
app.packageName,
app.versionCode
).path
).listFiles()
if (files?.isNotEmpty() == true) install() else purchase()
}
else -> {
flip(1)
purchase()
@@ -660,10 +615,6 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
("$installedVersion${app.versionName} (${app.versionCode})")
btn.setText(R.string.action_update)
btn.addOnClickListener { startDownload() }
btn.addOnLongClickListener {
startDownload(true)
true
}
} else {
binding.layoutDetailsApp.txtLine3.text = installedVersion
btn.setText(R.string.action_open)
@@ -689,15 +640,6 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
startDownload()
}
}
btn.addOnLongClickListener {
if (authData.isAnonymous && !app.isFree) {
toast(R.string.toast_purchase_blocked)
} else {
btn.setText(R.string.download_metadata)
startDownload(true)
}
true
}
if (uninstallActionEnabled) {
binding.layoutDetailsToolbar.toolbar.invalidateMenu()
}

View File

@@ -30,13 +30,11 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.aurora.extensions.isRAndAbove
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.room.download.Download
import com.aurora.store.databinding.FragmentUpdatesBinding
import com.aurora.store.util.PathUtil
@@ -48,7 +46,6 @@ import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.all.UpdatesViewModel
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@@ -186,24 +183,6 @@ class UpdatesFragment : BaseFragment(R.layout.fragment_updates) {
}
.positiveAction { _ -> updateSingle(app) }
.negativeAction { _ -> cancelSingle(app) }
.installAction { _ ->
val files = File(
PathUtil.getAppDownloadDir(
requireContext(),
app.packageName,
app.versionCode
).path
).listFiles()
// Downloaded files are missing, trigger re-download
if (files.isNullOrEmpty()) {
updateSingle(app)
return@installAction
}
val apkFiles = files.filter { it.path.endsWith(".apk") }
viewModel.install(requireContext(), app.packageName, apkFiles)
}
)
}
}

View File

@@ -20,15 +20,12 @@
package com.aurora.store.viewmodel.all
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.viewModelScope
import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.util.DownloadWorkerUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
@@ -70,18 +67,6 @@ class UpdatesViewModel @Inject constructor(
}
}
@Synchronized
fun install(context: Context, packageName: String, files: List<File>) {
try {
viewModelScope.launch(Dispatchers.IO) {
AppInstaller.getInstance(context).getPreferredInstaller()
.install(packageName, files)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to install app", exception)
}
}
fun download(app: App) {
viewModelScope.launch { downloadWorkerUtil.enqueueApp(app) }
}

View File

@@ -9,7 +9,6 @@ import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.ExodusReport
import com.aurora.store.data.model.Report
import com.aurora.store.data.network.HttpClient
@@ -17,7 +16,6 @@ import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.util.DownloadWorkerUtil
import com.google.gson.GsonBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -146,18 +144,6 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch { downloadWorkerUtil.cancelDownload(app.packageName) }
}
@Synchronized
fun install(context: Context, packageName: String, files: List<File>) {
try {
viewModelScope.launch(Dispatchers.IO) {
AppInstaller.getInstance(context).getPreferredInstaller()
.install(packageName, files)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to install app", exception)
}
}
private fun parseResponse(response: String, packageName: String): List<Report> {
try {
val gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)