diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2f6fa966f..a3ee7f376 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index df78d010f..b09d0debf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -126,6 +126,19 @@
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"
tools:ignore="MissingClass" />
+
+
+
+
+
+
diff --git a/app/src/main/java/com/aurora/store/AuroraApplication.kt b/app/src/main/java/com/aurora/store/AuroraApplication.kt
index 29bfc511a..f6dfdf591 100644
--- a/app/src/main/java/com/aurora/store/AuroraApplication.kt
+++ b/app/src/main/java/com/aurora/store/AuroraApplication.kt
@@ -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()
+ companion object {
val enqueuedInstalls: MutableSet = 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(
diff --git a/app/src/main/java/com/aurora/store/MainActivity.kt b/app/src/main/java/com/aurora/store/MainActivity.kt
index 12b4c31be..1330ef41d 100644
--- a/app/src/main/java/com/aurora/store/MainActivity.kt
+++ b/app/src/main/java/com/aurora/store/MainActivity.kt
@@ -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)
diff --git a/app/src/main/java/com/aurora/store/data/downloader/RequestBuilder.kt b/app/src/main/java/com/aurora/store/data/downloader/RequestBuilder.kt
index 4412867d3..adba57b56 100644
--- a/app/src/main/java/com/aurora/store/data/downloader/RequestBuilder.kt
+++ b/app/src/main/java/com/aurora/store/data/downloader/RequestBuilder.kt
@@ -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)
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
index 7eea8dae5..82f672b26 100644
--- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt
+++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt
@@ -6,5 +6,9 @@ enum class DownloadStatus {
CANCELLED,
COMPLETED,
QUEUED,
- UNAVAILABLE
+ UNAVAILABLE;
+
+ companion object {
+ val finished = listOf(FAILED, CANCELLED, COMPLETED)
+ }
}
diff --git a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt
new file mode 100644
index 000000000..7da8d85ca
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt
@@ -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
+}
diff --git a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt
new file mode 100644
index 000000000..2166a9225
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/data/room/download/Download.kt b/app/src/main/java/com/aurora/store/data/room/download/Download.kt
new file mode 100644
index 000000000..728f1f6ac
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/download/Download.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/aurora/store/data/room/download/DownloadDao.kt b/app/src/main/java/com/aurora/store/data/room/download/DownloadDao.kt
new file mode 100644
index 000000000..9901494ba
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/data/room/download/DownloadDao.kt
@@ -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>
+
+ @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)
+}
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
index 7d747610e..e15b23699 100644
--- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
+++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt
@@ -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()
- .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()
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): 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.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)
}
}
diff --git a/app/src/main/java/com/aurora/store/util/DownloadWorkerUtil.kt b/app/src/main/java/com/aurora/store/util/DownloadWorkerUtil.kt
new file mode 100644
index 000000000..d867462a3
--- /dev/null
+++ b/app/src/main/java/com/aurora/store/util/DownloadWorkerUtil.kt
@@ -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()
+ .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
+ )
+ }
+}
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 d4615c6b7..54f98929b 100644
--- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt
+++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt
@@ -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()
}
diff --git a/app/src/main/java/com/aurora/store/util/PathUtil.kt b/app/src/main/java/com/aurora/store/util/PathUtil.kt
index 7be183997..3332f41f9 100644
--- a/app/src/main/java/com/aurora/store/util/PathUtil.kt
+++ b/app/src/main/java/com/aurora/store/util/PathUtil.kt
@@ -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}"
}
diff --git a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt
index 8c479741c..f97f4f3ce 100644
--- a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt
+++ b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt
@@ -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)
diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
index 77649518c..05a0dfe36 100644
--- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
+++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt
@@ -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()
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) {
try {