diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f3f3648e2..a924e1b68 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -96,6 +96,11 @@
+
+
{
+ return flow {
+ var bytesCopied: Long = 0
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var bytes = read(buffer)
+ while (bytes >= 0) {
+ out.write(buffer, 0, bytes)
+ bytesCopied += bytes
+ bytes = read(buffer)
+ // Emit stream progress in percentage
+ emit((bytesCopied * 100 / streamSize).toInt())
+ }
+ }.flowOn(Dispatchers.IO).distinctUntilChanged()
+}
diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt
new file mode 100644
index 000000000..7eea8dae5
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt
@@ -0,0 +1,10 @@
+package com.aurora.store.data.model
+
+enum class DownloadStatus {
+ DOWNLOADING,
+ FAILED,
+ CANCELLED,
+ COMPLETED,
+ QUEUED,
+ UNAVAILABLE
+}
diff --git a/app/src/main/java/com/aurora/store/data/model/Request.kt b/app/src/main/java/com/aurora/store/data/model/Request.kt
new file mode 100644
index 000000000..6f628e25e
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/model/Request.kt
@@ -0,0 +1,7 @@
+package com.aurora.store.data.model
+
+data class Request(
+ val url: String,
+ val filePath: String,
+ val size: Long
+)
diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
new file mode 100644
index 000000000..d2b091feb
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
@@ -0,0 +1,192 @@
+package com.aurora.store.data.work
+
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+import android.util.Log
+import androidx.work.CoroutineWorker
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy.REPLACE
+import androidx.work.ForegroundInfo
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.aurora.extensions.copyTo
+import com.aurora.extensions.isQAndAbove
+import com.aurora.gplayapi.data.models.App
+import com.aurora.gplayapi.helpers.PurchaseHelper
+import com.aurora.store.data.model.DownloadStatus
+import com.aurora.store.data.model.Request
+import com.aurora.store.data.network.HttpClient
+import com.aurora.store.data.providers.AuthProvider
+import com.aurora.store.util.NotificationUtil
+import com.aurora.store.util.PathUtil
+import com.google.gson.Gson
+import java.io.File
+import java.net.URL
+import java.nio.file.Path
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.withContext
+import kotlin.io.path.Path
+import kotlin.io.path.absolutePathString
+import kotlin.io.path.createDirectories
+import com.aurora.gplayapi.data.models.File as GPlayFile
+
+class DownloadWorker(private val appContext: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(appContext, workerParams) {
+
+ companion object {
+ const val DOWNLOAD_DATA = "DOWNLOAD_DATA"
+ const val DOWNLOAD_PROGRESS = "DOWNLOAD_PROGRESS"
+
+ private const val TAG = "DownloadWorker"
+
+ fun downloadApp(context: Context, app: App) {
+ Log.i(TAG, "Downloading ${app.packageName}")
+
+ val downloadData = Data.Builder()
+ .putString(DOWNLOAD_DATA, Gson().toJson(app))
+ .build()
+
+ val work = OneTimeWorkRequestBuilder()
+ .setInputData(downloadData)
+ .addTag(app.versionCode.toString())
+ .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniqueWork(app.packageName, REPLACE, work)
+ }
+ }
+
+ private lateinit var app: App
+ private lateinit var appDownloadDir: Path
+ private var downloading = false
+
+ private val TAG = DownloadWorker::class.java.simpleName
+ private val notificationID = 200
+
+ private val gson = Gson()
+
+ override suspend fun doWork(): Result {
+ // Purchase the app (free apps needs to be purchased too)
+ val authData = AuthProvider.with(appContext).getAuthData()
+ val purchaseHelper = PurchaseHelper(authData)
+ .using(HttpClient.getPreferredClient(appContext))
+
+ // Try to parse input data into a valid app
+ withContext(Dispatchers.Default) {
+ try {
+ app = gson.fromJson(inputData.getString(DOWNLOAD_DATA), App::class.java)
+ appDownloadDir = Path(PathUtil.getPackageDirectory(appContext, app.packageName))
+ } catch (exception: Exception) {
+ Log.e(TAG, "Failed parsing requested app!", exception)
+ notifyStatus(DownloadStatus.FAILED)
+ return@withContext Result.failure()
+ }
+ }
+
+ // Set work/service to foreground on < Android 12.0
+ setForeground(getForegroundInfo())
+
+ // Bail out if file list is empty
+ val files = purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType)
+ if (files.isEmpty()) {
+ Log.i(TAG, "Nothing to download!")
+ notifyStatus(DownloadStatus.COMPLETED)
+ return Result.success()
+ }
+
+ // Download and verify all files exists
+ Path(appDownloadDir.absolutePathString(), app.versionCode.toString()).createDirectories()
+
+ val requestList = getDownloadRequest(files)
+ requestList.forEach { request ->
+ downloading = true
+ downloadFile(request)
+ while (downloading && !isStopped) {
+ delay(1000)
+ }
+ }
+
+ // Mark download as completed
+ notifyStatus(DownloadStatus.COMPLETED)
+ Log.i(TAG, "Finished downloading ${app.packageName}")
+ return Result.success()
+ }
+
+ private fun getDownloadRequest(files: List): List {
+ val downloadList = mutableListOf()
+ files.filter { it.url.isNotBlank() }.forEach {
+ val filePath = when (it.type) {
+ GPlayFile.FileType.BASE,
+ GPlayFile.FileType.SPLIT -> PathUtil.getApkDownloadFile(appContext, app, it)
+
+ GPlayFile.FileType.OBB,
+ GPlayFile.FileType.PATCH -> PathUtil.getObbDownloadFile(app, it)
+ }
+ downloadList.add(Request(it.url, filePath, it.size))
+ }
+ return downloadList
+ }
+
+ private suspend fun downloadFile(request: Request): Result {
+ return withContext(Dispatchers.IO) {
+ val requestFile = File(request.filePath)
+ try {
+ requestFile.createNewFile()
+ URL(request.url).openStream().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}")
+ notifyStatus(DownloadStatus.FAILED)
+ downloading = false
+ return@withContext Result.failure()
+ }
+ downloading = false
+ return@withContext Result.success()
+ } catch (exception: Exception) {
+ Log.e(TAG, "Failed to download ${request.filePath}!", exception)
+ requestFile.delete()
+ notifyStatus(DownloadStatus.FAILED)
+ downloading = false
+ return@withContext Result.failure()
+ }
+ }
+ }
+
+ private suspend fun onProgress(progress: Int) {
+ setProgress(Data.Builder().putInt(DOWNLOAD_PROGRESS, progress).build())
+ notifyStatus(DownloadStatus.DOWNLOADING, progress, notificationID)
+ }
+
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ val notification = NotificationUtil.getDownloadNotification(
+ appContext,
+ app,
+ DownloadStatus.QUEUED,
+ 0,
+ id
+ )
+ return if (isQAndAbove()) {
+ ForegroundInfo(notificationID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(notificationID, notification)
+ }
+ }
+
+ private fun notifyStatus(status: DownloadStatus, progress: Int = 100, dID: Int = -1) {
+ val notificationManager =
+ appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager
+ val notification =
+ NotificationUtil.getDownloadNotification(appContext, app, status, progress, id)
+ notificationManager.notify(if (dID != -1) dID else app.packageName.hashCode(), notification)
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt
index 5e3d65fec..253e3678d 100644
--- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt
+++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt
@@ -13,12 +13,14 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.navigation.NavDeepLinkBuilder
+import androidx.work.WorkManager
import com.aurora.Constants
import com.aurora.extensions.getStyledAttributeColor
import com.aurora.extensions.isMAndAbove
import com.aurora.gplayapi.data.models.App
import com.aurora.store.MainActivity
import com.aurora.store.R
+import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.receiver.DownloadCancelReceiver
import com.aurora.store.data.receiver.DownloadPauseReceiver
import com.aurora.store.data.receiver.DownloadResumeReceiver
@@ -26,6 +28,7 @@ import com.aurora.store.data.receiver.InstallReceiver
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchGroup
import com.tonyodev.fetch2.Status
+import java.util.UUID
object NotificationUtil {
@@ -195,6 +198,68 @@ object NotificationUtil {
return builder.build()
}
+ fun getDownloadNotification(
+ context: Context,
+ app: App,
+ status: DownloadStatus,
+ progress: Int,
+ workID: UUID
+ ): Notification {
+ val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_GENERAL)
+ builder.setContentTitle(app.displayName)
+ builder.color = ContextCompat.getColor(context, R.color.colorAccent)
+ builder.setContentIntent(getContentIntentForDownloads(context))
+
+ when (status) {
+ DownloadStatus.CANCELLED -> {
+ builder.setSmallIcon(R.drawable.ic_download_cancel)
+ builder.setContentText(context.getString(R.string.download_canceled))
+ builder.color = Color.RED
+ builder.setCategory(Notification.CATEGORY_ERROR)
+ }
+
+ DownloadStatus.FAILED -> {
+ builder.setSmallIcon(R.drawable.ic_download_fail)
+ builder.setContentText(context.getString(R.string.download_failed))
+ builder.color = Color.RED
+ builder.setCategory(Notification.CATEGORY_ERROR)
+ }
+
+ DownloadStatus.COMPLETED -> if (progress == 100) {
+ builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
+ builder.setContentText(context.getString(R.string.download_completed))
+ builder.setAutoCancel(true)
+ builder.setCategory(Notification.CATEGORY_STATUS)
+ builder.setContentIntent(getContentIntentForDetails(context, app))
+ }
+
+ DownloadStatus.DOWNLOADING, DownloadStatus.QUEUED -> {
+ builder.setSmallIcon(android.R.drawable.stat_sys_download)
+ builder.setContentText(
+ if (progress == 0) {
+ context.getString(R.string.download_queued)
+ } else {
+ context.getString(R.string.alt_download_progress)
+ }
+ )
+ builder.setOngoing(true)
+ builder.setCategory(Notification.CATEGORY_PROGRESS)
+ builder.setProgress(100, progress, progress == 0)
+ builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
+ builder.addAction(
+ NotificationCompat.Action.Builder(
+ R.drawable.ic_download_cancel,
+ context.getString(R.string.action_cancel),
+ WorkManager.getInstance(context).createCancelPendingIntent(workID)
+ ).build()
+ )
+ }
+
+ else -> {}
+ }
+ return builder.build()
+ }
+
fun getInstallNotification(context: Context, app: App, content: String?): Notification {
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERT)
builder.color = context.getStyledAttributeColor(R.color.colorAccent)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d62550253..34572d101 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -141,6 +141,7 @@
"Pause all"
"Paused • %1$d / %2$d"
"Downloading • %1$d / %2$d • %3$s"
+ "Downloading"
"Queued"
"Resume all"
"Estimating"