DownloadWorkerUtil: Use room database to store downloads

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2023-12-04 10:31:43 +05:30
parent acd2e7a586
commit 4c932bd048
16 changed files with 428 additions and 224 deletions

View File

@@ -193,6 +193,16 @@ dependencies {
ksp("com.google.dagger:hilt-android-compiler:$hiltVersion")
implementation("com.google.dagger:hilt-android:$hiltVersion")
val hiltWorkVersion = "1.1.0"
ksp("androidx.hilt:hilt-compiler:$hiltWorkVersion")
implementation("androidx.hilt:hilt-work:$hiltWorkVersion")
//Room
val roomVersion = "2.6.1"
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("androidx.room:room-runtime:$roomVersion")
// LeakCanary
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}

View File

@@ -126,6 +126,19 @@
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"
tools:ignore="MissingClass" />
<!-- Hilt (WorkManager) -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- If you are using androidx.startup to initialize other components -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<receiver android:name=".data.receiver.DownloadResumeReceiver" />
<receiver android:name=".data.receiver.DownloadPauseReceiver" />
<receiver android:name=".data.receiver.DownloadCancelReceiver" />

View File

@@ -22,26 +22,39 @@ package com.aurora.store
import android.app.Application
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.aurora.extensions.isPAndAbove
import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.downloader.DownloadManager
import com.aurora.store.data.receiver.PackageManagerReceiver
import com.aurora.store.data.service.NotificationService
import com.aurora.store.data.work.DownloadWorker
import com.aurora.store.util.CommonUtil
import com.aurora.store.util.DownloadWorkerUtil
import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PackageUtil
import com.tonyodev.fetch2.Fetch
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import org.lsposed.hiddenapibypass.HiddenApiBypass
@HiltAndroidApp
class AuroraApplication : Application() {
class AuroraApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var downloadWorkerUtil: DownloadWorkerUtil
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.INFO)
.setWorkerFactory(workerFactory)
.build()
private lateinit var fetch: Fetch
companion object{
val enqueuedDownloads = mutableSetOf<App>()
companion object {
val enqueuedInstalls: MutableSet<String> = mutableSetOf()
}
@@ -61,7 +74,7 @@ class AuroraApplication : Application() {
fetch = DownloadManager.with(this).fetch
// Initialize DownloadWorker to observe and trigger downloads
DownloadWorker.initDownloadWorker(applicationContext)
downloadWorkerUtil.init()
//Register broadcast receiver for package install/uninstall
ContextCompat.registerReceiver(

View File

@@ -72,7 +72,6 @@ import com.aurora.store.view.ui.sheets.SelfUpdateSheet
import com.aurora.store.viewmodel.MainViewModel
import com.google.android.material.bottomnavigation.BottomNavigationView
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
@AndroidEntryPoint
@@ -96,7 +95,6 @@ class MainActivity : AppCompatActivity() {
R.id.updatesFragment
)
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
applyThemeAccent()
super.onCreate(savedInstanceState)

View File

@@ -59,9 +59,9 @@ object RequestBuilder {
fun buildRequest(context: Context, app: App, file: File): Request {
val fileName = when (file.type) {
File.FileType.BASE,
File.FileType.SPLIT -> PathUtil.getApkDownloadFile(context, app, file)
File.FileType.SPLIT -> PathUtil.getApkDownloadFile(context, app.packageName, app.versionCode, file)
File.FileType.OBB,
File.FileType.PATCH -> PathUtil.getObbDownloadFile(app, file)
File.FileType.PATCH -> PathUtil.getObbDownloadFile(app.packageName, file)
}
return Request(file.url, fileName).apply {
attachMetaData(context, app)

View File

@@ -6,5 +6,9 @@ enum class DownloadStatus {
CANCELLED,
COMPLETED,
QUEUED,
UNAVAILABLE
UNAVAILABLE;
companion object {
val finished = listOf(FAILED, CANCELLED, COMPLETED)
}
}

View File

@@ -0,0 +1,11 @@
package com.aurora.store.data.room
import androidx.room.Database
import androidx.room.RoomDatabase
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
@Database(entities = [Download::class], version = 1, exportSchema = false)
abstract class AuroraDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
}

View File

@@ -0,0 +1,31 @@
package com.aurora.store.data.room
import android.content.Context
import androidx.room.Room
import com.aurora.store.data.room.download.DownloadDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RoomModule {
private const val DATABASE = "aurora_database"
@Singleton
@Provides
fun providesRoomInstance(@ApplicationContext context: Context): AuroraDatabase {
return Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE)
.fallbackToDestructiveMigration()
.build()
}
@Provides
fun providesDownloadDao(auroraDatabase: AuroraDatabase): DownloadDao {
return auroraDatabase.downloadDao()
}
}

View File

@@ -0,0 +1,47 @@
package com.aurora.store.data.room.download
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.model.DownloadStatus
@Entity(tableName = "download")
data class Download(
@PrimaryKey val packageName: String,
val versionCode: Int,
val offerType: Int,
val isInstalled: Boolean,
val displayName: String,
val iconURL: String,
val size: Long,
val id: Int,
var status: DownloadStatus,
var progress: Int,
var speed: Long,
var timeRemaining: Long,
var totalFiles: Int,
var downloadedFiles: Int
) {
val isFinished get() = status in DownloadStatus.finished
companion object {
fun fromApp(app: App): Download {
return Download(
app.packageName,
app.versionCode,
app.offerType,
app.isInstalled,
app.displayName,
app.iconArtwork.url,
app.size,
app.id,
DownloadStatus.QUEUED,
0,
0L,
0L,
0,
0
)
}
}
}

View File

@@ -0,0 +1,27 @@
package com.aurora.store.data.room.download
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface DownloadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(download: Download)
@Update
suspend fun update(download: Download)
@Query("SELECT * FROM download")
fun downloads(): Flow<List<Download>>
@Query("SELECT * FROM download WHERE packageName = :packageName")
suspend fun getDownload(packageName: String): Download?
@Query("DELETE FROM download WHERE packageName = :packageName")
suspend fun delete(packageName: String)
}

View File

@@ -6,147 +6,54 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy.KEEP
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aurora.Constants
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.AuroraApplication
import com.aurora.store.data.model.DownloadInfo
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.data.receiver.InstallReceiver
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.util.DownloadWorkerUtil
import com.aurora.store.util.NotificationUtil
import com.aurora.store.util.PathUtil
import com.google.gson.Gson
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import java.io.File
import java.net.URL
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.NonCancellable
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteRecursively
import kotlin.properties.Delegates
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaDuration
import com.aurora.gplayapi.data.models.File as GPlayFile
class DownloadWorker(private val appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
@HiltWorker
class DownloadWorker @AssistedInject constructor(
private val downloadDao: DownloadDao,
@Assisted private val appContext: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val DOWNLOAD_WORKER = "DOWNLOAD_WORKER"
const val DOWNLOAD_PROGRESS = "DOWNLOAD_PROGRESS"
const val DOWNLOAD_TIME = "DOWNLOAD_TIME"
const val DOWNLOAD_SPEED = "DOWNLOAD_SPEED"
private const val DOWNLOAD_APP = "DOWNLOAD_APP"
private const val DOWNLOAD_UPDATE = "DOWNLOAD_UPDATE"
private const val notificationID = 200
private val cleanupStates = listOf(
WorkInfo.State.CANCELLED,
WorkInfo.State.FAILED
)
fun initDownloadWorker(applicationContext: Context) {
GlobalScope.launch {
WorkManager.getInstance(applicationContext)
.getWorkInfosByTagFlow(DOWNLOAD_WORKER)
.collectLatest { downloadsList ->
try {
if (downloadsList.all { it.state.isFinished }) {
// Do cleanup for last download if required
downloadsList.getOrNull(0)?.let { workInfo ->
if (workInfo.state in cleanupStates) {
onFailure(
applicationContext,
AuroraApplication.enqueuedDownloads.first()
)
}
}
// Check and trigger download if enqueue list is not empty
if (AuroraApplication.enqueuedDownloads.isNotEmpty()) {
val app = AuroraApplication.enqueuedDownloads.first()
Log.i(DOWNLOAD_WORKER, "Downloading ${app.packageName}")
downloadApp(applicationContext, app)
}
}
} catch (exception: Exception) {
Log.i(DOWNLOAD_WORKER, "Failed to download enqueued apps", exception)
}
}
}
}
fun isEnqueued(packageName: String): Boolean {
return AuroraApplication.enqueuedDownloads.any { it.packageName == packageName }
}
fun enqueueApp(context: Context, app: App) {
if (AuroraApplication.enqueuedDownloads.isEmpty()) downloadApp(context, app)
AuroraApplication.enqueuedDownloads.add(app)
}
fun cancelDownload(context: Context, app: App) {
WorkManager.getInstance(context).cancelAllWorkByTag(app.packageName)
}
fun cancelAll(context: Context, downloads: Boolean = true, updates: Boolean = true) {
val workManager = WorkManager.getInstance(context)
if (downloads) workManager.cancelAllWorkByTag(DOWNLOAD_APP)
if (updates) workManager.cancelAllWorkByTag(DOWNLOAD_UPDATE)
}
private fun downloadApp(context: Context, app: App) {
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
.addTag(DOWNLOAD_WORKER)
.addTag(app.packageName)
.addTag(app.versionCode.toString())
.addTag(if (app.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.keepResultsForAtLeast(7.days.toJavaDuration())
.build()
// Ensure all app downloads are unique to preserve individual records
WorkManager.getInstance(context)
.enqueueUniqueWork("${DOWNLOAD_WORKER}/${app.packageName}", KEEP, work)
}
@OptIn(ExperimentalPathApi::class)
private fun onFailure(context: Context, app: App) {
Log.i(DOWNLOAD_WORKER, "Cleaning up!")
PathUtil.getAppDownloadDir(context, app.packageName, app.versionCode)
.deleteRecursively()
with (context.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) {
cancel(notificationID)
}
AuroraApplication.enqueuedDownloads.remove(app)
}
}
private lateinit var app: App
private lateinit var download: Download
private lateinit var notificationManager: NotificationManager
private var downloading = false
private val NOTIFICATION_ID = 200
private var totalBytes by Delegates.notNull<Long>()
private var totalProgress = 0
private var downloadedBytes = 0L
@@ -154,6 +61,18 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
private val TAG = DownloadWorker::class.java.simpleName
override suspend fun doWork(): Result {
// Try to parse input data into a valid app
try {
val downloadData = inputData.getString(DownloadWorkerUtil.DOWNLOAD_DATA)
download = Gson().fromJson(downloadData, Download::class.java)
} catch (exception: Exception) {
Log.e(TAG, "Failed to parse download data", exception)
return Result.failure()
}
// Set work/service to foreground on < Android 12.0
setForeground(getForegroundInfo())
// Purchase the app (free apps needs to be purchased too)
val authData = AuthProvider.with(appContext).getAuthData()
val purchaseHelper = PurchaseHelper(authData)
@@ -162,46 +81,40 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
notificationManager =
appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager
// Try to parse input data into a valid app
try {
app = AuroraApplication.enqueuedDownloads.first()
} catch (exception: Exception) {
Log.e(TAG, "No apps enqueued for downloads", exception)
return 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)
val files =
purchaseHelper.purchase(download.packageName, download.versionCode, download.offerType)
if (files.isEmpty()) {
Log.i(TAG, "Nothing to download!")
notifyStatus(DownloadStatus.COMPLETED)
return Result.success()
notifyStatus(DownloadStatus.FAILED)
return Result.failure()
}
// Download and verify all files exists
totalBytes = files.sumOf { it.size }
PathUtil.getAppDownloadDir(appContext, app.packageName, app.versionCode).createDirectories()
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode)
.createDirectories()
if (files.any { it.type == GPlayFile.FileType.OBB || it.type == GPlayFile.FileType.PATCH }) {
PathUtil.getObbDownloadDir(app.packageName).createDirectories()
PathUtil.getObbDownloadDir(download.packageName).createDirectories()
}
val requestList = getDownloadRequest(files)
download.totalFiles = requestList.size
requestList.forEach { request ->
downloading = true
runCatching { downloadFile(request) }
runCatching { downloadFile(request); download.downloadedFiles++ }
.onSuccess { downloading = false }
.onFailure {
Log.e(TAG, "Failed to download ${app.packageName}", it)
Log.e(TAG, "Failed to download ${download.packageName}", it)
downloading = false
onFailure()
return Result.failure()
}
while (downloading) {
delay(1000)
if (isStopped) {
onFailure()
break
}
}
@@ -211,30 +124,44 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
// Mark download as completed
notifyStatus(DownloadStatus.COMPLETED)
Log.i(TAG, "Finished downloading ${app.packageName}")
Log.i(TAG, "Finished downloading ${download.packageName}")
// Notify for installation
Intent(appContext, InstallReceiver::class.java).also {
it.action = InstallReceiver.ACTION_INSTALL_APP
it.putExtra(Constants.STRING_APP, app.packageName)
it.putExtra(Constants.STRING_VERSION, app.versionCode)
it.putExtra(Constants.STRING_APP, download.packageName)
it.putExtra(Constants.STRING_VERSION, download.versionCode)
appContext.sendBroadcast(it)
}
// Remove the app from the list
AuroraApplication.enqueuedDownloads.remove(app)
return Result.success()
}
@OptIn(ExperimentalPathApi::class)
private suspend fun onFailure() {
withContext(NonCancellable) {
Log.i(TAG, "Cleaning up!")
notifyStatus(DownloadStatus.FAILED)
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode)
.deleteRecursively()
with(appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) {
cancel(NOTIFICATION_ID)
}
}
}
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.BASE, GPlayFile.FileType.SPLIT -> {
PathUtil.getApkDownloadFile(
appContext, download.packageName, download.versionCode, it
)
}
GPlayFile.FileType.OBB,
GPlayFile.FileType.PATCH -> PathUtil.getObbDownloadFile(app, it)
GPlayFile.FileType.OBB, GPlayFile.FileType.PATCH -> {
PathUtil.getObbDownloadFile(download.packageName, it)
}
}
downloadList.add(Request(it.url, filePath, it.size))
}
@@ -281,37 +208,38 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
downloadedBytes += downloadInfo.bytesCopied
}
val data = Data.Builder()
.putInt(DOWNLOAD_PROGRESS, progress)
.putLong(DOWNLOAD_SPEED, downloadInfo.speed)
.putLong(DOWNLOAD_TIME, bytesRemaining / speed * 1000)
.build()
download.apply {
this.status = DownloadStatus.DOWNLOADING
this.progress = progress
this.speed = downloadInfo.speed
this.timeRemaining = bytesRemaining / speed * 1000
}
downloadDao.update(download)
setProgress(data)
notifyStatus(DownloadStatus.DOWNLOADING, progress, notificationID)
notifyStatus(DownloadStatus.DOWNLOADING, NOTIFICATION_ID)
totalProgress = progress
}
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = NotificationUtil.getDownloadNotification(
appContext,
app,
DownloadStatus.QUEUED,
0,
id
)
val notification = NotificationUtil.getDownloadNotification(appContext, download, id)
return if (isQAndAbove()) {
ForegroundInfo(notificationID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
ForegroundInfo(NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(notificationID, notification)
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
private fun notifyStatus(status: DownloadStatus, progress: Int = 100, dID: Int = -1) {
val notification =
NotificationUtil.getDownloadNotification(appContext, app, status, progress, id)
notificationManager.notify(if (dID != -1) dID else app.packageName.hashCode(), notification)
private suspend fun notifyStatus(status: DownloadStatus, dID: Int = -1) {
// Update database for all status except downloading which is handled onProgress
if (status != DownloadStatus.DOWNLOADING) {
download.status = status
downloadDao.update(download)
}
val notification = NotificationUtil.getDownloadNotification(appContext, download, id)
val notificationID = if (dID != -1) dID else download.packageName.hashCode()
notificationManager.notify(notificationID, notification)
}
}

View File

@@ -0,0 +1,103 @@
package com.aurora.store.util
import android.content.Context
import android.util.Log
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import com.aurora.gplayapi.data.models.App
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.download.DownloadDao
import com.aurora.store.data.work.DownloadWorker
import com.google.gson.Gson
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(DelicateCoroutinesApi::class)
class DownloadWorkerUtil @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadDao: DownloadDao,
private val gson: Gson
) {
companion object {
const val DOWNLOAD_WORKER = "DOWNLOAD_WORKER"
const val DOWNLOAD_DATA = "DOWNLOAD_DATA"
private const val DOWNLOAD_APP = "DOWNLOAD_APP"
private const val DOWNLOAD_UPDATE = "DOWNLOAD_UPDATE"
private const val PACKAGE_NAME = "PACKAGE_NAME"
private const val VERSION_CODE = "VERSION_CODE"
}
val downloadsList = downloadDao.downloads()
.stateIn(GlobalScope, SharingStarted.WhileSubscribed(), emptyList())
fun init() {
// Run cleanup for last finished download and drop it database
GlobalScope.launch {
downloadDao.downloads()
.collectLatest { list ->
// Check and trigger next download in queue, if any
if (!list.any { it.status == DownloadStatus.DOWNLOADING }) {
val enqueuedDownloads = list.filter { it.status == DownloadStatus.QUEUED }
enqueuedDownloads.firstOrNull()?.let {
try {
Log.i(DOWNLOAD_WORKER, "Downloading ${it.packageName}")
trigger(it)
} catch (exception: Exception) {
Log.i(DOWNLOAD_WORKER, "Failed to download app", exception)
downloadDao.delete(it.packageName)
}
}
}
}
}
}
suspend fun enqueueApp(app: App) {
downloadDao.insert(Download.fromApp(app))
}
fun cancelDownload(packageName: String) {
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName")
}
fun cancelAll(context: Context, downloads: Boolean = true, updates: Boolean = true) {
val workManager = WorkManager.getInstance(context)
if (downloads) workManager.cancelAllWorkByTag(DOWNLOAD_APP)
if (updates) workManager.cancelAllWorkByTag(DOWNLOAD_UPDATE)
}
private fun trigger(download: Download) {
val inputData = Data.Builder()
.putString(DOWNLOAD_DATA, gson.toJson(download))
.build()
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
.addTag(DOWNLOAD_WORKER)
.addTag("$PACKAGE_NAME:${download.packageName}")
.addTag("$VERSION_CODE:${download.versionCode}")
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
.setInputData(inputData)
.build()
// Ensure all app downloads are unique to preserve individual records
WorkManager.getInstance(context)
.enqueueUniqueWork(
"${DOWNLOAD_WORKER}/${download.packageName}",
ExistingWorkPolicy.KEEP, work
)
}
}

View File

@@ -19,6 +19,7 @@ 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.data.room.download.Download as AuroraDownload
import com.aurora.store.R
import com.aurora.store.data.activity.InstallActivity
import com.aurora.store.data.model.DownloadStatus
@@ -180,7 +181,7 @@ object NotificationUtil {
Status.COMPLETED -> if (fetchGroup.groupDownloadProgress == 100) {
builder.setAutoCancel(true)
builder.setContentIntent(getContentIntentForDetails(context, app))
builder.setContentIntent(getContentIntentForDetails(context, app.packageName))
builder.setStyle(progressBigText)
}
@@ -200,17 +201,15 @@ object NotificationUtil {
fun getDownloadNotification(
context: Context,
app: App,
status: DownloadStatus,
progress: Int,
download: AuroraDownload,
workID: UUID
): Notification {
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_GENERAL)
builder.setContentTitle(app.displayName)
builder.setContentTitle(download.displayName)
builder.color = ContextCompat.getColor(context, R.color.colorAccent)
builder.setContentIntent(getContentIntentForDownloads(context))
when (status) {
when (download.status) {
DownloadStatus.CANCELLED -> {
builder.setSmallIcon(R.drawable.ic_download_cancel)
builder.setContentText(context.getString(R.string.download_canceled))
@@ -225,17 +224,17 @@ object NotificationUtil {
builder.setCategory(Notification.CATEGORY_ERROR)
}
DownloadStatus.COMPLETED -> if (progress == 100) {
DownloadStatus.COMPLETED -> if (download.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))
builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_install,
context.getString(R.string.action_install),
getInstallIntent(context, app.packageName, app.versionCode)
getInstallIntent(context, download.packageName, download.versionCode)
).build()
)
}
@@ -243,15 +242,20 @@ object NotificationUtil {
DownloadStatus.DOWNLOADING, DownloadStatus.QUEUED -> {
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentText(
if (progress == 0) {
if (download.progress == 0) {
context.getString(R.string.download_queued)
} else {
context.getString(R.string.alt_download_progress)
context.getString(
R.string.download_progress,
download.downloadedFiles,
download.totalFiles,
CommonUtil.humanReadableByteSpeed(download.speed, true)
)
}
)
builder.setOngoing(true)
builder.setCategory(Notification.CATEGORY_PROGRESS)
builder.setProgress(100, progress, progress == 0)
builder.setProgress(100, download.progress, download.progress == 0)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.addAction(
NotificationCompat.Action.Builder(
@@ -274,7 +278,7 @@ object NotificationUtil {
setSmallIcon(R.drawable.ic_install)
setContentTitle(app.displayName)
setContentText(content)
setContentIntent(getContentIntentForDetails(context, app))
setContentIntent(getContentIntentForDetails(context, app.packageName))
setSubText(app.packageName)
}
return builder.build()
@@ -307,12 +311,12 @@ object NotificationUtil {
return PendingIntent.getBroadcast(context, groupId, intent, flags)
}
private fun getContentIntentForDetails(context: Context, app: App?): PendingIntent {
private fun getContentIntentForDetails(context: Context, packageName: String): PendingIntent {
return NavDeepLinkBuilder(context)
.setGraph(R.navigation.mobile_navigation)
.setDestination(R.id.appDetailsFragment)
.setComponentName(MainActivity::class.java)
.setArguments(bundleOf("packageName" to app!!.packageName))
.setArguments(bundleOf("packageName" to packageName))
.createPendingIntent()
}

View File

@@ -58,8 +58,13 @@ object PathUtil {
return Path(getPackageDirectory(context, packageName), versionCode.toString())
}
fun getApkDownloadFile(context: Context, app: App, file: File): String {
return getVersionDirectory(context, app.packageName, app.versionCode) + "/${file.name}"
fun getApkDownloadFile(
context: Context,
packageName: String,
versionCode: Int,
file: File
): String {
return getVersionDirectory(context, packageName, versionCode) + "/${file.name}"
}
fun getApkDownloadFile(context: Context, packageName: String, versionCode: Int): String {
@@ -84,9 +89,9 @@ object PathUtil {
return "${getExternalPath(context)}/Exports/"
}
private fun getObbDownloadPath(app: App): String {
private fun getObbDownloadPath(packageName: String): String {
return Environment.getExternalStorageDirectory()
.toString() + "/Android/obb/" + app.packageName
.toString() + "/Android/obb/" + packageName
}
fun getObbDownloadDir(packageName: String): Path {
@@ -97,8 +102,8 @@ object PathUtil {
)
}
fun getObbDownloadFile(app: App, file: File): String {
val obbDir = getObbDownloadPath(app)
fun getObbDownloadFile(packageName: String, file: File): String {
val obbDir = getObbDownloadPath(packageName)
return "$obbDir/${file.name}"
}

View File

@@ -41,8 +41,6 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.work.WorkInfo
import androidx.work.WorkManager
import coil.load
import coil.transform.RoundedCornersTransformation
import com.airbnb.epoxy.EpoxyRecyclerView
@@ -70,7 +68,6 @@ import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.installer.RootInstaller
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.work.DownloadWorker
import com.aurora.store.databinding.FragmentDetailsBinding
import com.aurora.store.databinding.LayoutDetailsBetaBinding
import com.aurora.store.databinding.LayoutDetailsDescriptionBinding
@@ -101,6 +98,8 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.util.Locale
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlin.io.path.pathString
@AndroidEntryPoint
@@ -119,7 +118,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
private val startForStorageManagerResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (isRAndAbove() && Environment.isExternalStorageManager()) {
DownloadWorker.enqueueApp(requireContext(), app)
viewModel.download(app)
} else {
toast(R.string.permissions_denied)
}
@@ -127,7 +126,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
private val startForPermissions =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
DownloadWorker.enqueueApp(requireContext(), app)
viewModel.download(app)
} else {
toast(R.string.permissions_denied)
}
@@ -239,35 +238,30 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
// Downloads
binding.layoutDetailsInstall.imgCancel.setOnClickListener {
DownloadWorker.cancelDownload(it.context, app)
viewModel.cancelDownload(app)
if (downloadStatus != DownloadStatus.DOWNLOADING) flip(0)
}
val uniqueWorkName = "${DownloadWorker.DOWNLOAD_WORKER}/${app.packageName}"
WorkManager.getInstance(view.context)
.getWorkInfosForUniqueWorkLiveData(uniqueWorkName)
.observe(viewLifecycleOwner) { workList ->
workList.getOrNull(0)?.let {
if (it.state.isFinished) flip(0) else flip(1)
when (it.state) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING -> {
downloadStatus = DownloadStatus.DOWNLOADING
updateProgress(
it.progress.getInt(DownloadWorker.DOWNLOAD_PROGRESS, 0),
it.progress.getLong(DownloadWorker.DOWNLOAD_SPEED, -1),
it.progress.getLong(DownloadWorker.DOWNLOAD_TIME, -1)
)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.downloadsList
.filter { list -> list.any { it.packageName == app.packageName } }
.collectLatest { downloadsList ->
val download = downloadsList.find { it.packageName == app.packageName }
download?.let {
downloadStatus = it.status
if (it.isFinished) flip(0) else flip(1)
when (it.status) {
DownloadStatus.QUEUED,
DownloadStatus.DOWNLOADING -> {
updateProgress(it.progress, it.speed, it.timeRemaining)
}
else -> {}
}
WorkInfo.State.FAILED -> downloadStatus = DownloadStatus.FAILED
WorkInfo.State.CANCELLED -> downloadStatus = DownloadStatus.CANCELLED
WorkInfo.State.SUCCEEDED -> downloadStatus = DownloadStatus.COMPLETED
else -> {}
}
}
}
}
// Reviews
viewLifecycleOwner.lifecycleScope.launch {
@@ -587,6 +581,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
}
else -> {
flip(1)
purchase()
}
}
@@ -604,7 +599,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
)
} else {
DownloadWorker.enqueueApp(requireContext(), app)
viewModel.download(app)
}
} else {
if (ContextCompat.checkSelfPermission(
@@ -612,13 +607,13 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
DownloadWorker.enqueueApp(requireContext(), app)
viewModel.download(app)
} else {
startForPermissions.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
} else {
DownloadWorker.enqueueApp(requireContext(), app)
viewModel.download(app)
}
}
@@ -679,8 +674,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
binding.layoutDetailsToolbar.toolbar.invalidateMenu()
}
} else {
val downloading = downloadStatus == DownloadStatus.DOWNLOADING
if (DownloadWorker.isEnqueued(app.packageName) && !downloading) {
if (downloadStatus == DownloadStatus.QUEUED) {
flip(1)
} else if (app.isFree) {
btn.setText(R.string.action_install)

View File

@@ -14,15 +14,21 @@ import com.aurora.store.data.model.ExodusReport
import com.aurora.store.data.model.Report
import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.util.DownloadWorkerUtil
import com.google.gson.GsonBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.lang.reflect.Modifier
import javax.inject.Inject
class AppDetailsViewModel : ViewModel() {
@HiltViewModel
class AppDetailsViewModel @Inject constructor(
private val downloadWorkerUtil: DownloadWorkerUtil
) : ViewModel() {
private val TAG = AppDetailsViewModel::class.java.simpleName
@@ -44,6 +50,8 @@ class AppDetailsViewModel : ViewModel() {
private val _testingProgramStatus = MutableSharedFlow<TestingProgramStatus?>()
val testingProgramStatus = _testingProgramStatus.asSharedFlow()
val downloadsList get() = downloadWorkerUtil.downloadsList
fun fetchAppDetails(context: Context, packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -129,6 +137,14 @@ class AppDetailsViewModel : ViewModel() {
}
}
fun download(app: App) {
viewModelScope.launch { downloadWorkerUtil.enqueueApp(app) }
}
fun cancelDownload(app: App) {
viewModelScope.launch { downloadWorkerUtil.cancelDownload(app.packageName) }
}
@Synchronized
fun install(context: Context, packageName: String, files: List<Any>) {
try {