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"