DownloadWorker: Initial implementation
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
@@ -96,6 +96,11 @@
|
|||||||
<service android:name=".data.service.UpdateService" />
|
<service android:name=".data.service.UpdateService" />
|
||||||
<service android:name=".data.service.SelfUpdateService" />
|
<service android:name=".data.service.SelfUpdateService" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileProvider"
|
android:authorities="${applicationId}.fileProvider"
|
||||||
|
|||||||
24
app/src/main/java/com/aurora/extensions/InputStream.kt
Normal file
24
app/src/main/java/com/aurora/extensions/InputStream.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.aurora.store.data.model
|
||||||
|
|
||||||
|
enum class DownloadStatus {
|
||||||
|
DOWNLOADING,
|
||||||
|
FAILED,
|
||||||
|
CANCELLED,
|
||||||
|
COMPLETED,
|
||||||
|
QUEUED,
|
||||||
|
UNAVAILABLE
|
||||||
|
}
|
||||||
7
app/src/main/java/com/aurora/store/data/model/Request.kt
Normal file
7
app/src/main/java/com/aurora/store/data/model/Request.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package com.aurora.store.data.model
|
||||||
|
|
||||||
|
data class Request(
|
||||||
|
val url: String,
|
||||||
|
val filePath: String,
|
||||||
|
val size: Long
|
||||||
|
)
|
||||||
192
app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
Normal file
192
app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,14 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
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.extensions.getStyledAttributeColor
|
import com.aurora.extensions.getStyledAttributeColor
|
||||||
import com.aurora.extensions.isMAndAbove
|
import com.aurora.extensions.isMAndAbove
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
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.model.DownloadStatus
|
||||||
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
import com.aurora.store.data.receiver.DownloadCancelReceiver
|
||||||
import com.aurora.store.data.receiver.DownloadPauseReceiver
|
import com.aurora.store.data.receiver.DownloadPauseReceiver
|
||||||
import com.aurora.store.data.receiver.DownloadResumeReceiver
|
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.Download
|
||||||
import com.tonyodev.fetch2.FetchGroup
|
import com.tonyodev.fetch2.FetchGroup
|
||||||
import com.tonyodev.fetch2.Status
|
import com.tonyodev.fetch2.Status
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
object NotificationUtil {
|
object NotificationUtil {
|
||||||
|
|
||||||
@@ -195,6 +198,68 @@ object NotificationUtil {
|
|||||||
return builder.build()
|
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 {
|
fun getInstallNotification(context: Context, app: App, content: String?): Notification {
|
||||||
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERT)
|
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ALERT)
|
||||||
builder.color = context.getStyledAttributeColor(R.color.colorAccent)
|
builder.color = context.getStyledAttributeColor(R.color.colorAccent)
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
<string name="download_pause_all">"Pause all"</string>
|
<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_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="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_queued">"Queued"</string>
|
||||||
<string name="download_resume_all">"Resume all"</string>
|
<string name="download_resume_all">"Resume all"</string>
|
||||||
<string name="download_speed_estimating">"Estimating"</string>
|
<string name="download_speed_estimating">"Estimating"</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user