Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 67df168a authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

feat(update): process update requests one by one

Process enqueued update requests one by one only after the current installation finishes or fails. InstallOrchestrator manages both app installation and updates queue and allows only one item to process at a given moment.
parent 2ce05edd
Loading
Loading
Loading
Loading
+2 −5
Original line number Diff line number Diff line
@@ -46,7 +46,8 @@ class AppInstallationFacade @Inject constructor(
        application: Application,
        isAnUpdate: Boolean = false
    ): Boolean {
        val appInstall = installationRequest.create(application)
        val isUpdate = isAnUpdate || application.status == Status.UPDATABLE
        val appInstall = installationRequest.create(application, isUpdateRequest = isUpdate)

        if (application.source == Source.PLAY_STORE) {
            val libs = application.dependentLibraries.ifEmpty {
@@ -66,10 +67,6 @@ class AppInstallationFacade @Inject constructor(
            appInstall.sharedLibs = libs
        }

        val isUpdate = isAnUpdate ||
            application.status == Status.UPDATABLE ||
            appManager.isAppInstalled(appInstall)

        return enqueueAppForInstallation(appInstall, isUpdate, application.isSystemApp)
    }

+1 −15
Original line number Diff line number Diff line
@@ -18,13 +18,10 @@

package foundation.e.apps.data.install.core

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
import foundation.e.apps.data.application.AppManager
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.install.core.helper.PreEnqueueChecker
import foundation.e.apps.data.install.workmanager.InstallWorkManager
import foundation.e.apps.data.install.wrapper.AppEventDispatcher
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.preference.PlayStoreAuthStore
@@ -34,7 +31,6 @@ import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject
class InstallationEnqueuer @Inject constructor(
    @ApplicationContext private val context: Context,
    private val preEnqueueChecker: PreEnqueueChecker,
    private val appManager: AppManager,
    private val sessionRepository: SessionRepository,
@@ -55,11 +51,7 @@ class InstallationEnqueuer @Inject constructor(

                canEnqueue(appInstall, isAnUpdate) -> {
                    appManager.updateAwaiting(appInstall)
                    // Enqueueing installation work is managed by InstallOrchestrator#observeDownloads().
                    // This method only handles update work.
                    if (isAnUpdate) {
                        enqueueUpdate(appInstall)
                    }
                    // InstallOrchestrator owns WorkManager dispatch for queued installs and updates.
                    true
                }

@@ -101,12 +93,6 @@ class InstallationEnqueuer @Inject constructor(
        }
    }

    private fun enqueueUpdate(appInstall: AppInstall) {
        val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName)
        InstallWorkManager.enqueueWork(context, appInstall, true)
        Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName")
    }

    suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean {
        return preEnqueueChecker.canEnqueue(appInstall, isAnUpdate)
    }
+3 −2
Original line number Diff line number Diff line
@@ -26,7 +26,7 @@ import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.installation.model.InstallationType
import javax.inject.Inject
class InstallationRequest @Inject constructor() {
    fun create(application: Application): AppInstall {
    fun create(application: Application, isUpdateRequest: Boolean = false): AppInstall {
        val appInstall = AppInstall(
            application._id,
            application.source.toInstallationSource(),
@@ -41,7 +41,8 @@ class InstallationRequest @Inject constructor() {
            application.latest_version_code,
            application.offer_type,
            application.isFree,
            application.originalSize
            application.originalSize,
            isUpdateRequest = isUpdateRequest
        ).also {
            it.contentRating = application.contentRating
        }
+10 −11
Original line number Diff line number Diff line
@@ -46,15 +46,14 @@ class InstallationCompletionHandler @Inject constructor(
    }

    override suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) {
        if (!isUpdateWork) {
        if (!isUpdateWork || appInstall?.isUpdateRequest != true) {
            return
        }

        appInstall?.let {
        val packageStatus = appManager.getInstallationStatus(appInstall)

        if (packageStatus == Status.INSTALLED) {
                UpdatesDao.addSuccessfullyUpdatedApp(it)
            UpdatesDao.addSuccessfullyUpdatedApp(appInstall)
        }

        if (isUpdateCompleted()) {
@@ -62,10 +61,10 @@ class InstallationCompletionHandler @Inject constructor(
            UpdatesDao.clearSuccessfullyUpdatedApps()
        }
    }
    }

    private suspend fun isUpdateCompleted(): Boolean {
        val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter {
            it.isUpdateRequest &&
                !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status)
        }

+64 −18
Original line number Diff line number Diff line
@@ -19,7 +19,6 @@
package foundation.e.apps.data.install.workmanager

import android.content.Context
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.await
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -30,11 +29,16 @@ import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject

class InstallOrchestrator @Inject constructor(
@@ -43,29 +47,68 @@ class InstallOrchestrator @Inject constructor(
    private val appManager: AppManager,
    private val appInstallRepository: AppInstallRepository
) {
    private val isInitialized = AtomicBoolean(false)
    private val reevaluationRequests = Channel<Unit>(Channel.CONFLATED)

    fun init() {
        if (!isInitialized.compareAndSet(false, true)) return

        scope.launch {
            runCatching {
                cancelFailedDownloads()
            }.onFailure { throwable ->
                handleFailure(throwable, "Failed to reconcile startup downloads")
            }
            processQueueReevaluationRequests()
            observeDownloads()
            observeInstallWork()
        }
    }

    private fun observeDownloads() {
        appInstallRepository.getDownloads().onEach { list ->
        appInstallRepository.getDownloads().onEach {
            requestQueueReevaluation()
        }.launchIn(scope)
    }

    private fun observeInstallWork() {
        WorkManager.getInstance(context)
            .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME)
            .map { workInfos -> workInfos.any { !it.state.isFinished } }
            .distinctUntilChanged()
            .runningFold(false to false) { previous, isActive -> previous.second to isActive }
            .onEach { (wasActive, isActive) ->
                if (!wasActive || isActive) return@onEach
                requestQueueReevaluation()
            }.launchIn(scope)
    }

    private fun processQueueReevaluationRequests() {
        scope.launch {
            while (reevaluationRequests.receiveCatching().isSuccess) {
                reevaluateQueuedDownloads()
            }
        }
    }

    private fun requestQueueReevaluation() {
        reevaluationRequests.trySend(Unit)
    }

    private suspend fun reevaluateQueuedDownloads() {
        runCatching {
                if (list.none { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }) {
                    list.find { it.status == Status.AWAITING }
            val downloads = appInstallRepository.getDownloadList()
            val hasActiveDownloadOrInstall =
                downloads.any { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }
            val hasActiveInstallWork = hasActiveInstallWork()

            if (!hasActiveDownloadOrInstall && !hasActiveInstallWork) {
                downloads.find { it.status == Status.AWAITING }
                    ?.let { queuedDownload -> trigger(queuedDownload) }
            }
        }.onFailure { throwable ->
            handleFailure(throwable, "Failed to enqueue download worker")
        }
        }.launchIn(scope)
    }

    private fun handleFailure(throwable: Throwable, errorMessage: String) {
@@ -80,7 +123,7 @@ class InstallOrchestrator @Inject constructor(
        val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName)

        runCatching {
            val operation = InstallWorkManager.enqueueWork(context, download, false)
            val operation = InstallWorkManager.enqueueWork(context, download, download.isUpdateRequest)
            operation.await()
            Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName")
        }.onFailure { throwable ->
@@ -92,16 +135,19 @@ class InstallOrchestrator @Inject constructor(
        }
    }

    private suspend fun hasActiveInstallWork(): Boolean {
        val workInfos = WorkManager.getInstance(context)
            .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME)
            .firstOrNull()
            .orEmpty()

        return workInfos.any { !it.state.isFinished }
    }

    private suspend fun cancelFailedDownloads() {
        val workManager = WorkManager.getInstance(context)
        val apps = appInstallRepository.getDownloads().firstOrNull().orEmpty()

        val activeWorkStates = setOf(
            WorkInfo.State.ENQUEUED,
            WorkInfo.State.RUNNING,
            WorkInfo.State.BLOCKED
        )

        apps.filter { app ->
            app.status in listOf(
                Status.DOWNLOADING,
@@ -111,7 +157,7 @@ class InstallOrchestrator @Inject constructor(
        }.forEach { app ->
            runCatching {
                val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty()
                val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates }
                val hasActiveWork = workInfos.any { info -> !info.state.isFinished }
                val hasActiveSession =
                    app.status == Status.INSTALLING && hasActiveInstallSession(app.packageName)

Loading