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:
Rahul Patel
2025-02-27 04:09:21 +05:30
parent 3d8706da0b
commit 667017aec6
4 changed files with 161 additions and 75 deletions

View File

@@ -160,6 +160,11 @@
android:name=".data.receiver.InstallerStatusReceiver" android:name=".data.receiver.InstallerStatusReceiver"
android:exported="false" /> android:exported="false" />
<!-- Cancel Download Receiver-->
<receiver
android:name=".data.receiver.DownloadCancelReceiver"
android:exported="false" />
<!-- SessionInstaller (as device owner) --> <!-- SessionInstaller (as device owner) -->
<receiver <receiver
android:name=".data.receiver.DeviceOwnerReceiver" android:name=".data.receiver.DeviceOwnerReceiver"

View File

@@ -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)
}
}
}
}

View File

@@ -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 package com.aurora.store.data.work
import android.app.NotificationManager import android.app.NotificationManager
@@ -38,7 +44,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -70,27 +75,32 @@ class DownloadWorker @AssistedInject constructor(
private var notificationId: Int = 200 private var notificationId: Int = 200
private var icon: Bitmap? = null private var icon: Bitmap? = null
private var downloading = false
private var totalBytes by Delegates.notNull<Long>() private var totalBytes by Delegates.notNull<Long>()
private var totalProgress = 0 private var totalProgress = 0
private var downloadedBytes = 0L private var downloadedBytes = 0L
private val TAG = DownloadWorker::class.java.simpleName 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 { override suspend fun doWork(): Result {
super.doWork() super.doWork()
// Bail out immediately if authData is not valid // Bail out immediately if authData is not valid
if (!authProvider.isSavedAuthDataValid()) { if (!authProvider.isSavedAuthDataValid()) return onFailure(Exceptions.InvalidAuthDataException)
Log.e(TAG, "AuthData is not valid, exiting!")
onFailure()
return Result.failure()
}
// Fetch required data for download // Fetch required data for download
try { try {
val packageName = val packageName = inputData.getString(DownloadHelper.PACKAGE_NAME)
inputData.getString(DownloadHelper.PACKAGE_NAME) ?: return Result.failure()
// Bail out if no package name is provided
if (packageName.isNullOrBlank()) return onFailure(Exceptions.NoPackageNameException)
notificationId = packageName.hashCode() notificationId = packageName.hashCode()
download = downloadDao.getDownload(packageName) download = downloadDao.getDownload(packageName)
@@ -102,110 +112,131 @@ class DownloadWorker @AssistedInject constructor(
icon = Bitmap.createScaledBitmap(bitmap, 96, 96, true) icon = Bitmap.createScaledBitmap(bitmap, 96, 96, true)
} }
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to parse download data", exception) return onFailure(exception)
onFailure(exception)
return Result.failure()
} }
// Set work/service to foreground on < Android 12.0 // Set work/service to foreground on < Android 12.0
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
// Bail out if file list is empty // Try to purchase the app if file list is empty
download.fileList = download.fileList.ifEmpty { download.fileList = download.fileList.ifEmpty {
purchase(download.packageName, download.versionCode, download.offerType) purchase(download.packageName, download.versionCode, download.offerType)
} }
if (download.fileList.isEmpty()) { // Bail out if file list is empty after purchase
Log.i(TAG, "Nothing to download!") if (download.fileList.isEmpty()) return onFailure(Exceptions.NothingToDownloadException)
onFailure()
return Result.failure()
}
// Create dirs & generate download request for files and shared libs (if any) // Create dirs & generate download request for files and shared libs (if any)
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode).mkdirs() PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode).mkdirs()
// Create OBB dir if required
if (download.fileList.requiresObbDir()) { if (download.fileList.requiresObbDir()) {
PathUtil.getObbDownloadDir(download.packageName).mkdirs() PathUtil.getObbDownloadDir(download.packageName).mkdirs()
} }
// Purchase the app (free apps needs to be purchased too) val files = mutableListOf<GPlayFile>()
val requestList = mutableListOf<GPlayFile>()
// Check if shared libs are present, if yes, handle them first
if (download.sharedLibs.isNotEmpty()) { if (download.sharedLibs.isNotEmpty()) {
download.sharedLibs.forEach { download.sharedLibs.forEach {
// Create shared lib download dir
PathUtil.getLibDownloadDir( PathUtil.getLibDownloadDir(
appContext, appContext,
download.packageName, download.packageName,
download.versionCode, download.versionCode,
it.packageName it.packageName
).mkdirs() ).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 // Update data for notification
download.totalFiles = requestList.size download.totalFiles = files.size
totalBytes = requestList.sumOf { it.size } totalBytes = files.sumOf { it.size }
// Update database with all latest purchases // Update database with all latest purchases
downloadDao.updateFiles(download.packageName, download.fileList) downloadDao.updateFiles(download.packageName, download.fileList)
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs) downloadDao.updateSharedLibs(download.packageName, download.sharedLibs)
// Download and verify all files exists // Download files
requestList.forEach { request -> val downloadSuccess = downloadFiles(download.packageName, files)
downloading = true
runCatching { downloadFile(request); download.downloadedFiles++ }
.onSuccess { downloading = false }
.onFailure {
Log.e(TAG, "Failed to download ${download.packageName}", it)
downloading = false
onFailure(it as Exception) // Report failure if download was stopped or failed
if (!downloadSuccess || isStopped) return onFailure(Exceptions.DownloadFailedException)
return Result.failure() // Verify downloaded files
} val verifySuccess = verifyFiles(download.packageName, files)
while (downloading) {
delay(1000)
val d = downloadDao.getDownload(download.packageName)
if (isStopped || d.downloadStatus == DownloadStatus.CANCELLED) {
onFailure(CancellationException())
break
}
}
}
try { // Report failure if verification failed
// Verify all downloaded files if (!verifySuccess || isStopped) return onFailure(Exceptions.VerificationFailedException)
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()
}
// Mark download as completed Log.i(TAG, "Finished downloading & verifying ${download.packageName}")
onSuccess() notifyStatus(DownloadStatus.COMPLETED)
return Result.success()
return onSuccess()
} }
private suspend fun onSuccess() { private suspend fun downloadFiles(
withContext(NonCancellable) { packageName: String,
Log.i(TAG, "Finished downloading ${download.packageName}") files: List<GPlayFile>
notifyStatus(DownloadStatus.COMPLETED) ): Boolean = withContext(Dispatchers.IO) {
for (file in files) {
try { try {
appInstaller.getPreferredInstaller().install(download) downloadFile(file)
} catch (exception: Exception) {
Log.e(TAG, "Failed to install ${download.packageName}", exception) 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) { 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) 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)
@@ -221,9 +252,11 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
// Remove all notifications
notificationManager.cancel(notificationId) notificationManager.cancel(notificationId)
return@withContext Result.failure()
} }
}
/** /**
* Purchases the app to get the download URL of the required files * 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. * @return A [Result] indicating whether the file was downloaded or not.
*/ */
private suspend fun downloadFile(gFile: GPlayFile): Result { private suspend fun downloadFile(gFile: GPlayFile): Result {
Log.i(TAG, "Downloading ${gFile.name}")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val file = PathUtil.getLocalFile(appContext, gFile, download) val file = PathUtil.getLocalFile(appContext, gFile, download)
@@ -338,7 +372,7 @@ class DownloadWorker @AssistedInject constructor(
override suspend fun getForegroundInfo(): ForegroundInfo { override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = if (this::download.isInitialized) { val notification = if (this::download.isInitialized) {
NotificationUtil.getDownloadNotification(appContext, download, id, icon) NotificationUtil.getDownloadNotification(appContext, download, icon)
} else { } else {
NotificationUtil.getDownloadNotification(appContext) NotificationUtil.getDownloadNotification(appContext)
} }
@@ -353,7 +387,6 @@ class DownloadWorker @AssistedInject constructor(
/** /**
* Notifies the user of the current status of the download. * Notifies the user of the current status of the download.
* @param status Current [DownloadStatus] * @param status Current [DownloadStatus]
* @param dID ID of the notification, defaults to hashCode of the download's packageName
*/ */
private suspend fun notifyStatus(status: DownloadStatus) { private suspend fun notifyStatus(status: DownloadStatus) {
// Update status in database // Update status in database
@@ -373,7 +406,7 @@ class DownloadWorker @AssistedInject constructor(
else -> {} else -> {}
} }
val notification = NotificationUtil.getDownloadNotification(appContext, download, id, icon) val notification = NotificationUtil.getDownloadNotification(appContext, download, icon)
notificationManager.notify(notificationId, notification) notificationManager.notify(notificationId, notification)
} }
@@ -384,6 +417,7 @@ class DownloadWorker @AssistedInject constructor(
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
private suspend fun verifyFile(gFile: GPlayFile): Boolean { private suspend fun verifyFile(gFile: GPlayFile): Boolean {
val file = PathUtil.getLocalFile(appContext, gFile, download) val file = PathUtil.getLocalFile(appContext, gFile, download)
Log.i(TAG, "Verifying $file")
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256 val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256 val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256

View File

@@ -15,13 +15,14 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.navigation.NavDeepLinkBuilder import androidx.navigation.NavDeepLinkBuilder
import androidx.work.WorkManager
import com.aurora.Constants import com.aurora.Constants
import com.aurora.store.MainActivity import com.aurora.store.MainActivity
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.activity.InstallActivity 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.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus 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.download.Download
import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.Update
import java.util.UUID import java.util.UUID
@@ -88,7 +89,6 @@ object NotificationUtil {
fun getDownloadNotification( fun getDownloadNotification(
context: Context, context: Context,
download: AuroraDownload, download: AuroraDownload,
workID: UUID,
largeIcon: Bitmap? = null largeIcon: Bitmap? = null
): Notification { ): Notification {
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS) val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_DOWNLOADS)
@@ -96,6 +96,17 @@ object NotificationUtil {
builder.setContentIntent(getContentIntentForDownloads(context)) builder.setContentIntent(getContentIntentForDownloads(context))
builder.setLargeIcon(largeIcon) 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) { when (download.downloadStatus) {
DownloadStatus.CANCELLED -> { DownloadStatus.CANCELLED -> {
builder.setSmallIcon(R.drawable.ic_download_cancel) builder.setSmallIcon(R.drawable.ic_download_cancel)
@@ -152,7 +163,7 @@ object NotificationUtil {
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
R.drawable.ic_download_cancel, R.drawable.ic_download_cancel,
context.getString(R.string.action_cancel), context.getString(R.string.action_cancel),
WorkManager.getInstance(context).createCancelPendingIntent(workID) pendingCancelIntent
).build() ).build()
) )
} }