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 {