DownloadWorker: simplify & improve code [1/2]
TODO: - Reduce notification frequency, create too many notifications resulting in notification actions being inaccesible. - Its a bad practive to bombard system with notification redraws
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.aurora.store.AuroraApp
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadCancelReceiver : BroadcastReceiver() {
|
||||
private val TAG = DownloadCancelReceiver::class.java.simpleName
|
||||
|
||||
@Inject
|
||||
lateinit var downloadHelper: DownloadHelper
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val packageName: String = intent?.getStringExtra("PACKAGE_NAME") ?: ""
|
||||
|
||||
if (packageName.isNotBlank()) {
|
||||
Log.d(TAG, "Received cancel download request for $packageName")
|
||||
AuroraApp.scope.launch(Dispatchers.IO) {
|
||||
downloadHelper.cancelDownload(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 Aurora OSS
|
||||
* SPDX-FileCopyrightText: 2025 The Calyx Institute
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.store.data.work
|
||||
|
||||
import android.app.NotificationManager
|
||||
@@ -38,7 +44,6 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -70,27 +75,32 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
private var notificationId: Int = 200
|
||||
private var icon: Bitmap? = null
|
||||
private var downloading = false
|
||||
private var totalBytes by Delegates.notNull<Long>()
|
||||
private var totalProgress = 0
|
||||
private var downloadedBytes = 0L
|
||||
|
||||
private val TAG = DownloadWorker::class.java.simpleName
|
||||
|
||||
object Exceptions {
|
||||
val InvalidAuthDataException = Exception("AuthData is invalid")
|
||||
val NoPackageNameException = Exception("No packagename provided")
|
||||
val NothingToDownloadException = Exception("Failed to purchase app")
|
||||
val DownloadFailedException = Exception("Download was failed or cancelled")
|
||||
val VerificationFailedException = Exception("Verification of downloaded files failed")
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
super.doWork()
|
||||
|
||||
// Bail out immediately if authData is not valid
|
||||
if (!authProvider.isSavedAuthDataValid()) {
|
||||
Log.e(TAG, "AuthData is not valid, exiting!")
|
||||
onFailure()
|
||||
return Result.failure()
|
||||
}
|
||||
if (!authProvider.isSavedAuthDataValid()) return onFailure(Exceptions.InvalidAuthDataException)
|
||||
|
||||
// Fetch required data for download
|
||||
try {
|
||||
val packageName =
|
||||
inputData.getString(DownloadHelper.PACKAGE_NAME) ?: return Result.failure()
|
||||
val packageName = inputData.getString(DownloadHelper.PACKAGE_NAME)
|
||||
|
||||
// Bail out if no package name is provided
|
||||
if (packageName.isNullOrBlank()) return onFailure(Exceptions.NoPackageNameException)
|
||||
|
||||
notificationId = packageName.hashCode()
|
||||
download = downloadDao.getDownload(packageName)
|
||||
@@ -102,110 +112,131 @@ class DownloadWorker @AssistedInject constructor(
|
||||
icon = Bitmap.createScaledBitmap(bitmap, 96, 96, true)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to parse download data", exception)
|
||||
onFailure(exception)
|
||||
return Result.failure()
|
||||
return onFailure(exception)
|
||||
}
|
||||
|
||||
// Set work/service to foreground on < Android 12.0
|
||||
setForeground(getForegroundInfo())
|
||||
|
||||
// Bail out if file list is empty
|
||||
// Try to purchase the app if file list is empty
|
||||
download.fileList = download.fileList.ifEmpty {
|
||||
purchase(download.packageName, download.versionCode, download.offerType)
|
||||
}
|
||||
|
||||
if (download.fileList.isEmpty()) {
|
||||
Log.i(TAG, "Nothing to download!")
|
||||
onFailure()
|
||||
return Result.failure()
|
||||
}
|
||||
// Bail out if file list is empty after purchase
|
||||
if (download.fileList.isEmpty()) return onFailure(Exceptions.NothingToDownloadException)
|
||||
|
||||
// Create dirs & generate download request for files and shared libs (if any)
|
||||
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode).mkdirs()
|
||||
|
||||
// Create OBB dir if required
|
||||
if (download.fileList.requiresObbDir()) {
|
||||
PathUtil.getObbDownloadDir(download.packageName).mkdirs()
|
||||
}
|
||||
|
||||
// Purchase the app (free apps needs to be purchased too)
|
||||
val requestList = mutableListOf<GPlayFile>()
|
||||
val files = mutableListOf<GPlayFile>()
|
||||
|
||||
// Check if shared libs are present, if yes, handle them first
|
||||
if (download.sharedLibs.isNotEmpty()) {
|
||||
download.sharedLibs.forEach {
|
||||
// Create shared lib download dir
|
||||
PathUtil.getLibDownloadDir(
|
||||
appContext,
|
||||
download.packageName,
|
||||
download.versionCode,
|
||||
it.packageName
|
||||
).mkdirs()
|
||||
it.fileList = it.fileList.ifEmpty { purchase(it.packageName, it.versionCode, 0) }
|
||||
requestList.addAll(it.fileList)
|
||||
|
||||
// Purchase shared lib if file list is empty
|
||||
it.fileList = it.fileList.ifEmpty {
|
||||
purchase(it.packageName, it.versionCode, 0)
|
||||
}
|
||||
files.addAll(it.fileList)
|
||||
}
|
||||
}
|
||||
requestList.addAll(download.fileList)
|
||||
files.addAll(download.fileList)
|
||||
|
||||
// Update data for notification
|
||||
download.totalFiles = requestList.size
|
||||
totalBytes = requestList.sumOf { it.size }
|
||||
download.totalFiles = files.size
|
||||
totalBytes = files.sumOf { it.size }
|
||||
|
||||
// Update database with all latest purchases
|
||||
downloadDao.updateFiles(download.packageName, download.fileList)
|
||||
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs)
|
||||
|
||||
// Download and verify all files exists
|
||||
requestList.forEach { request ->
|
||||
downloading = true
|
||||
runCatching { downloadFile(request); download.downloadedFiles++ }
|
||||
.onSuccess { downloading = false }
|
||||
.onFailure {
|
||||
Log.e(TAG, "Failed to download ${download.packageName}", it)
|
||||
downloading = false
|
||||
// Download files
|
||||
val downloadSuccess = downloadFiles(download.packageName, files)
|
||||
|
||||
onFailure(it as Exception)
|
||||
// Report failure if download was stopped or failed
|
||||
if (!downloadSuccess || isStopped) return onFailure(Exceptions.DownloadFailedException)
|
||||
|
||||
return Result.failure()
|
||||
}
|
||||
while (downloading) {
|
||||
delay(1000)
|
||||
val d = downloadDao.getDownload(download.packageName)
|
||||
if (isStopped || d.downloadStatus == DownloadStatus.CANCELLED) {
|
||||
onFailure(CancellationException())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify downloaded files
|
||||
val verifySuccess = verifyFiles(download.packageName, files)
|
||||
|
||||
try {
|
||||
// Verify all downloaded files
|
||||
Log.i(TAG, "Verifying downloaded files")
|
||||
notifyStatus(DownloadStatus.VERIFYING)
|
||||
requestList.forEach { require(verifyFile(it)) }
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to verify downloaded files!", exception)
|
||||
onFailure(exception)
|
||||
return Result.failure()
|
||||
}
|
||||
// Report failure if verification failed
|
||||
if (!verifySuccess || isStopped) return onFailure(Exceptions.VerificationFailedException)
|
||||
|
||||
// Mark download as completed
|
||||
onSuccess()
|
||||
return Result.success()
|
||||
Log.i(TAG, "Finished downloading & verifying ${download.packageName}")
|
||||
notifyStatus(DownloadStatus.COMPLETED)
|
||||
|
||||
return onSuccess()
|
||||
}
|
||||
|
||||
private suspend fun onSuccess() {
|
||||
withContext(NonCancellable) {
|
||||
Log.i(TAG, "Finished downloading ${download.packageName}")
|
||||
notifyStatus(DownloadStatus.COMPLETED)
|
||||
|
||||
private suspend fun downloadFiles(
|
||||
packageName: String,
|
||||
files: List<GPlayFile>
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
for (file in files) {
|
||||
try {
|
||||
appInstaller.getPreferredInstaller().install(download)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to install ${download.packageName}", exception)
|
||||
downloadFile(file)
|
||||
|
||||
if (isStopped) {
|
||||
Log.w(TAG, "Download cancelled for $packageName")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
download.downloadedFiles++
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to download $packageName", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun verifyFiles(
|
||||
packageName: String,
|
||||
files: List<GPlayFile>
|
||||
): Boolean = withContext(NonCancellable) {
|
||||
notifyStatus(DownloadStatus.VERIFYING)
|
||||
|
||||
for (file in files) {
|
||||
try {
|
||||
verifyFile(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to verify $packageName : ${file.name}", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun onSuccess(): Result = withContext(NonCancellable) {
|
||||
return@withContext try {
|
||||
appInstaller.getPreferredInstaller().install(download)
|
||||
Result.success()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to install ${download.packageName}", exception)
|
||||
onFailure(exception)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onFailure(exception: Exception = Exception("Something went wrong!")) {
|
||||
private suspend fun onFailure(exception: Exception = Exception("Something went wrong!")): Result =
|
||||
withContext(NonCancellable) {
|
||||
Log.i(TAG, "Failed downloading ${download.packageName}")
|
||||
Log.e(TAG, "Job failed: ${download.packageName}", exception)
|
||||
|
||||
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
|
||||
if (isSAndAbove && stopReason in cancelReasons) {
|
||||
notifyStatus(DownloadStatus.CANCELLED)
|
||||
@@ -221,9 +252,11 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all notifications
|
||||
notificationManager.cancel(notificationId)
|
||||
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchases the app to get the download URL of the required files
|
||||
@@ -258,6 +291,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
* @return A [Result] indicating whether the file was downloaded or not.
|
||||
*/
|
||||
private suspend fun downloadFile(gFile: GPlayFile): Result {
|
||||
Log.i(TAG, "Downloading ${gFile.name}")
|
||||
return withContext(Dispatchers.IO) {
|
||||
val file = PathUtil.getLocalFile(appContext, gFile, download)
|
||||
|
||||
@@ -338,7 +372,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = if (this::download.isInitialized) {
|
||||
NotificationUtil.getDownloadNotification(appContext, download, id, icon)
|
||||
NotificationUtil.getDownloadNotification(appContext, download, icon)
|
||||
} else {
|
||||
NotificationUtil.getDownloadNotification(appContext)
|
||||
}
|
||||
@@ -353,7 +387,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
/**
|
||||
* Notifies the user of the current status of the download.
|
||||
* @param status Current [DownloadStatus]
|
||||
* @param dID ID of the notification, defaults to hashCode of the download's packageName
|
||||
*/
|
||||
private suspend fun notifyStatus(status: DownloadStatus) {
|
||||
// Update status in database
|
||||
@@ -373,7 +406,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
else -> {}
|
||||
}
|
||||
|
||||
val notification = NotificationUtil.getDownloadNotification(appContext, download, id, icon)
|
||||
val notification = NotificationUtil.getDownloadNotification(appContext, download, icon)
|
||||
notificationManager.notify(notificationId, notification)
|
||||
}
|
||||
|
||||
@@ -384,6 +417,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
private suspend fun verifyFile(gFile: GPlayFile): Boolean {
|
||||
val file = PathUtil.getLocalFile(appContext, gFile, download)
|
||||
Log.i(TAG, "Verifying $file")
|
||||
|
||||
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
|
||||
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256
|
||||
|
||||
@@ -15,13 +15,14 @@ import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.NavDeepLinkBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.aurora.Constants
|
||||
import com.aurora.store.MainActivity
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.activity.InstallActivity
|
||||
import com.aurora.store.data.helper.DownloadHelper
|
||||
import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.update.Update
|
||||
import java.util.UUID
|
||||
@@ -88,7 +89,6 @@ object NotificationUtil {
|
||||
fun getDownloadNotification(
|
||||
context: Context,
|
||||
download: AuroraDownload,
|
||||
workID: UUID,
|
||||
largeIcon: Bitmap? = null
|
||||
): Notification {
|
||||
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS)
|
||||
@@ -96,6 +96,17 @@ object NotificationUtil {
|
||||
builder.setContentIntent(getContentIntentForDownloads(context))
|
||||
builder.setLargeIcon(largeIcon)
|
||||
|
||||
val cancelIntent = Intent(context, DownloadCancelReceiver::class.java).apply {
|
||||
putExtra(DownloadHelper.PACKAGE_NAME, download.packageName)
|
||||
}
|
||||
|
||||
val pendingCancelIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
cancelIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
when (download.downloadStatus) {
|
||||
DownloadStatus.CANCELLED -> {
|
||||
builder.setSmallIcon(R.drawable.ic_download_cancel)
|
||||
@@ -152,7 +163,7 @@ object NotificationUtil {
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_download_cancel,
|
||||
context.getString(R.string.action_cancel),
|
||||
WorkManager.getInstance(context).createCancelPendingIntent(workID)
|
||||
pendingCancelIntent
|
||||
).build()
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user