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( data class Request(
val url: String, val url: String,
val filePath: 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 kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.net.URL import java.net.URL
import java.security.DigestInputStream
import java.security.MessageDigest
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
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
@@ -191,27 +193,40 @@ class DownloadWorker @AssistedInject constructor(
PathUtil.getObbDownloadFile(download.packageName, it) 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 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)
// If file exists, no need to download again
if (!shouldDownload(request)) {
Log.i(TAG, "$requestFile already exists")
return@withContext Result.success()
}
try { try {
val algorithm = if (request.sha256.isBlank()) "SHA-1" else "SHA-256"
val messageDigest = MessageDigest.getInstance(algorithm)
requestFile.createNewFile() requestFile.createNewFile()
URL(request.url).openStream().use { input -> DigestInputStream(URL(request.url).openStream(), messageDigest).use { input ->
requestFile.outputStream().use { requestFile.outputStream().use {
input.copyTo(it, request.size).collectLatest { p -> onProgress(p) } input.copyTo(it, request.size).collectLatest { p -> onProgress(p) }
} }
} }
// Ensure downloaded file exists
if (!File(request.filePath).exists()) { val sha = messageDigest.digest().toHexString()
Log.e(TAG, "Failed to find downloaded file at ${request.filePath}") if (!File(request.filePath).exists() || !(sha == request.sha1 || sha == request.sha256)) {
Log.e(TAG, "$requestFile is either missing or corrupt")
notifyStatus(DownloadStatus.FAILED) notifyStatus(DownloadStatus.FAILED)
return@withContext Result.failure() 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)
@@ -277,4 +292,26 @@ class DownloadWorker @AssistedInject constructor(
val notificationID = if (dID != -1) dID else download.packageName.hashCode() val notificationID = if (dID != -1) dID else download.packageName.hashCode()
notificationManager.notify(notificationID, notification) 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?) { fun addOnClickListener(onClickListener: OnClickListener?) {
B.btn.setOnClickListener(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.getString
import com.aurora.extensions.runOnUiThread import com.aurora.extensions.runOnUiThread
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.State
import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.databinding.ViewUpdateButtonBinding import com.aurora.store.databinding.ViewUpdateButtonBinding
@@ -77,7 +76,6 @@ class UpdateButton : RelativeLayout {
val displayChild = when (downloadStatus) { val displayChild = when (downloadStatus) {
DownloadStatus.QUEUED, DownloadStatus.QUEUED,
DownloadStatus.DOWNLOADING -> 2 DownloadStatus.DOWNLOADING -> 2
DownloadStatus.COMPLETED -> 3
else -> 0 else -> 0
} }
@@ -95,8 +93,4 @@ class UpdateButton : RelativeLayout {
fun addNegativeOnClickListener(onClickListener: OnClickListener?) { fun addNegativeOnClickListener(onClickListener: OnClickListener?) {
B.btnNegative.setOnClickListener(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.data.room.download.Download
import com.aurora.store.databinding.ViewAppUpdateBinding import com.aurora.store.databinding.ViewAppUpdateBinding
import com.aurora.store.util.CommonUtil import com.aurora.store.util.CommonUtil
import com.aurora.store.util.PathUtil
import com.aurora.store.view.epoxy.views.BaseView import com.aurora.store.view.epoxy.views.BaseView
import java.io.File
@ModelView( @ModelView(
autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, 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 -> { else -> {
B.progressDownload.progress = 0 B.progressDownload.progress = 0
B.progressDownload.invisible() B.progressDownload.invisible()
@@ -172,11 +156,6 @@ class AppUpdateView : RelativeLayout {
B.btnAction.addNegativeOnClickListener(onClickListener) B.btnAction.addNegativeOnClickListener(onClickListener)
} }
@CallbackProp
fun installAction(onClickListener: OnClickListener?) {
B.btnAction.addInstallOnClickListener(onClickListener)
}
@CallbackProp @CallbackProp
fun longClick(onClickListener: OnLongClickListener?) { fun longClick(onClickListener: OnLongClickListener?) {
B.layoutContent.setOnLongClickListener(onClickListener) B.layoutContent.setOnLongClickListener(onClickListener)

View File

@@ -52,7 +52,6 @@ import com.aurora.extensions.isRAndAbove
import com.aurora.extensions.runOnUiThread import com.aurora.extensions.runOnUiThread
import com.aurora.extensions.share import com.aurora.extensions.share
import com.aurora.extensions.show import com.aurora.extensions.show
import com.aurora.extensions.showDialog
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.AuthData
@@ -96,7 +95,6 @@ import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter 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 @Synchronized
private fun uninstallApp() { private fun uninstallApp() {
val installer = AppInstaller.getInstance(requireContext()).getPreferredInstaller() val installer = AppInstaller.getInstance(requireContext()).getPreferredInstaller()
@@ -554,31 +527,13 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
} }
@Synchronized @Synchronized
private fun startDownload(forceDownload: Boolean = false) { private fun startDownload() {
when (downloadStatus) { when (downloadStatus) {
DownloadStatus.DOWNLOADING -> { DownloadStatus.DOWNLOADING -> {
flip(1) flip(1)
toast("Already downloading") 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 -> { else -> {
flip(1) flip(1)
purchase() purchase()
@@ -660,10 +615,6 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
("$installedVersion${app.versionName} (${app.versionCode})") ("$installedVersion${app.versionName} (${app.versionCode})")
btn.setText(R.string.action_update) btn.setText(R.string.action_update)
btn.addOnClickListener { startDownload() } btn.addOnClickListener { startDownload() }
btn.addOnLongClickListener {
startDownload(true)
true
}
} else { } else {
binding.layoutDetailsApp.txtLine3.text = installedVersion binding.layoutDetailsApp.txtLine3.text = installedVersion
btn.setText(R.string.action_open) btn.setText(R.string.action_open)
@@ -689,15 +640,6 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
startDownload() 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) { if (uninstallActionEnabled) {
binding.layoutDetailsToolbar.toolbar.invalidateMenu() binding.layoutDetailsToolbar.toolbar.invalidateMenu()
} }

View File

@@ -30,13 +30,11 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.aurora.extensions.isRAndAbove import com.aurora.extensions.isRAndAbove
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.event.BusEvent 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.data.room.download.Download
import com.aurora.store.databinding.FragmentUpdatesBinding import com.aurora.store.databinding.FragmentUpdatesBinding
import com.aurora.store.util.PathUtil 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.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.all.UpdatesViewModel import com.aurora.store.viewmodel.all.UpdatesViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -186,24 +183,6 @@ class UpdatesFragment : BaseFragment(R.layout.fragment_updates) {
} }
.positiveAction { _ -> updateSingle(app) } .positiveAction { _ -> updateSingle(app) }
.negativeAction { _ -> cancelSingle(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 package com.aurora.store.viewmodel.all
import android.app.Application import android.app.Application
import android.content.Context
import android.util.Log import android.util.Log
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.util.DownloadWorkerUtil import com.aurora.store.util.DownloadWorkerUtil
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers 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) { fun download(app: App) {
viewModelScope.launch { downloadWorkerUtil.enqueueApp(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.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.ReviewsHelper 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.ExodusReport
import com.aurora.store.data.model.Report import com.aurora.store.data.model.Report
import com.aurora.store.data.network.HttpClient 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.aurora.store.util.DownloadWorkerUtil
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@@ -146,18 +144,6 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch { downloadWorkerUtil.cancelDownload(app.packageName) } 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> { private fun parseResponse(response: String, packageName: String): List<Report> {
try { try {
val gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC) val gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)

View File

@@ -70,18 +70,6 @@
android:textAppearance="@style/TextAppearance.Aurora.Line1" /> android:textAppearance="@style/TextAppearance.Aurora.Line1" />
</RelativeLayout> </RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_install"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_install"
android:textAppearance="@style/TextAppearance.Aurora.Line1" />
</RelativeLayout>
</ViewFlipper> </ViewFlipper>
</RelativeLayout> </RelativeLayout>