DownloadWorker: Initial implementation

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2023-10-25 18:09:03 +05:30
parent debb0bf920
commit 6f707b2a37
7 changed files with 304 additions and 0 deletions

View File

@@ -96,6 +96,11 @@
<service android:name=".data.service.UpdateService" />
<service android:name=".data.service.SelfUpdateService" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"

View File

@@ -0,0 +1,24 @@
package com.aurora.extensions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.InputStream
import java.io.OutputStream
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<Int> {
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()
}

View File

@@ -0,0 +1,10 @@
package com.aurora.store.data.model
enum class DownloadStatus {
DOWNLOADING,
FAILED,
CANCELLED,
COMPLETED,
QUEUED,
UNAVAILABLE
}

View File

@@ -0,0 +1,7 @@
package com.aurora.store.data.model
data class Request(
val url: String,
val filePath: String,
val size: Long
)

View File

@@ -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<DownloadWorker>()
.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<GPlayFile>): List<Request> {
val downloadList = mutableListOf<Request>()
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)
}
}

View File

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

View File

@@ -141,6 +141,7 @@
<string name="download_pause_all">"Pause all"</string>
<string name="download_paused">"Paused • <xliff:g id="completed_downloads">%1$d</xliff:g> / <xliff:g id="all_downloads">%2$d</xliff:g>"</string>
<string name="download_progress">"Downloading • <xliff:g id="completed_downloads">%1$d</xliff:g> / <xliff:g id="all_downloads">%2$d</xliff:g><xliff:g id="speed">%3$s</xliff:g>"</string>
<string name="alt_download_progress">"Downloading"</string>
<string name="download_queued">"Queued"</string>
<string name="download_resume_all">"Resume all"</string>
<string name="download_speed_estimating">"Estimating"</string>