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

Commit b07033bb authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '4125-improve-workmanager' into 'main'

refactor: improve work manager for app installation and update

See merge request !697
parents 7bb7a8cf 8fefe7b0
Loading
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -34,6 +34,7 @@ import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PkgManagerBR
import foundation.e.apps.data.install.updates.UpdatesWorkManager
import foundation.e.apps.data.install.workmanager.InstallOrchestrator
import foundation.e.apps.data.system.CustomUncaughtExceptionHandler
import foundation.e.apps.domain.preferences.SessionRepository
import foundation.e.apps.domain.preferences.updateinterval.GetUpdateIntervalUseCase
@@ -80,6 +81,9 @@ class AppLoungeApplication : Application(), Configuration.Provider {
    @Inject
    lateinit var pkgManagerBR: PkgManagerBR

    @Inject
    lateinit var installOrchestrator: InstallOrchestrator

    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onCreate() {
        super.onCreate()
@@ -129,6 +133,8 @@ class AppLoungeApplication : Application(), Configuration.Provider {
        if (!isRunningUnderRobolectric()) {
            removeStalledInstallationFromDb()
        }

        installOrchestrator.init()
    }

    private fun isRunningUnderRobolectric(): Boolean = Build.FINGERPRINT == "robolectric"
+4 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import foundation.e.apps.data.install.models.AppInstall
import kotlinx.coroutines.flow.Flow

@Dao
interface AppInstallDAO {
@@ -18,6 +19,9 @@ interface AppInstallDAO {
    @Query("SELECT * FROM fuseddownload")
    fun getDownloadLiveList(): LiveData<List<AppInstall>>

    @Query("SELECT * FROM fuseddownload")
    fun getDownloads(): Flow<List<AppInstall>>

    @Query("SELECT * FROM fuseddownload")
    suspend fun getDownloadList(): List<AppInstall>

+6 −9
Original line number Diff line number Diff line
@@ -18,14 +18,15 @@
package foundation.e.apps.data.install.updates

import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import foundation.e.apps.data.install.workmanager.WorkRequestConstraints
import foundation.e.apps.data.install.workmanager.WorkType
import timber.log.Timber
import java.util.concurrent.TimeUnit

@@ -45,7 +46,8 @@ object UpdatesWorkManager {

    private fun buildOneTimeWorkRequest(): OneTimeWorkRequest {
        return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply {
            setConstraints(buildWorkerConstraints())
            setConstraints(WorkRequestConstraints.build(WorkType.UpdateOneTime))
            setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
            addTag(USER_TAG)
        }.setInputData(
            Data.Builder()
@@ -54,18 +56,13 @@ object UpdatesWorkManager {
        ).build()
    }

    private fun buildWorkerConstraints() = Constraints.Builder().apply {
        setRequiresBatteryNotLow(true)
        setRequiredNetworkType(NetworkType.CONNECTED)
    }.build()

    private fun buildPeriodicWorkRequest(interval: Long): PeriodicWorkRequest {
        return PeriodicWorkRequest.Builder(
            UpdatesWorker::class.java,
            interval,
            TimeUnit.HOURS
        ).apply {
            setConstraints(buildWorkerConstraints())
            setConstraints(WorkRequestConstraints.build(WorkType.UpdatePeriodic))
            addTag(TAG)
        }.build()
    }
+9 −5
Original line number Diff line number Diff line
@@ -132,6 +132,8 @@ class AppInstallProcessor @Inject constructor(
        isAnUpdate: Boolean = false,
        isSystemApp: Boolean = false
    ): Boolean {
        val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName)

        return try {
            val user = sessionRepository.awaitUser()
            if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) {
@@ -144,13 +146,15 @@ class AppInstallProcessor @Inject constructor(
            if (!canEnqueue(appInstall)) return false

            appInstallComponents.appManagerWrapper.updateAwaiting(appInstall)
            InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate)

            // Use only for update work for now. For installation work, see InstallOrchestrator#observeDownloads()
            if (isAnUpdate) {
                InstallWorkManager.enqueueWork(context, appInstall, true)
                Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName")
            }
            true
        } catch (e: Exception) {
            Timber.e(
                e,
                "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}"
            )
            Timber.e(e, "UPDATE: Failed to enqueue unique work for ${appInstall.packageName}")
            appInstallComponents.appManagerWrapper.installationIssue(appInstall)
            false
        }
+145 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

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
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

class InstallOrchestrator @Inject constructor(
    @param:ApplicationContext val context: Context,
    @param:IoCoroutineScope private val scope: CoroutineScope,
    private val appManagerWrapper: AppManagerWrapper,
    private val installDao: AppInstallDAO
) {

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

    private fun observeDownloads() {
        installDao.getDownloads().onEach { list ->
            runCatching {
                if (list.none { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }) {
                    list.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) {
        when (throwable) {
            is CancellationException -> throw throwable
            is Exception -> Timber.e(throwable, errorMessage)
            else -> throw throwable
        }
    }

    private suspend fun trigger(download: AppInstall) {
        val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName)

        runCatching {
            val operation = InstallWorkManager.enqueueWork(context, download, false)
            operation.await()
            Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName")
        }.onFailure { throwable ->
            handleFailure(
                throwable,
                "INSTALL: Failed to enqueue unique work for ${download.name}: $uniqueWorkName"
            )
            appManagerWrapper.installationIssue(download)
        }
    }

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

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

        apps.filter { app ->
            app.status in listOf(
                Status.DOWNLOADING,
                Status.DOWNLOADED,
                Status.INSTALLING
            )
        }.forEach { app ->
            runCatching {
                val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty()
                val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates }
                val hasActiveSession =
                    app.status == Status.INSTALLING && hasActiveInstallSession(app.packageName)

                when {
                    hasActiveWork || hasActiveSession -> return@forEach
                    appManagerWrapper.isFusedDownloadInstalled(app) -> {
                        appManagerWrapper.updateDownloadStatus(app, Status.INSTALLED)
                    }

                    else -> {
                        Timber.i(
                            "INSTALL: Marking stale download as issue for ${app.name}/${app.packageName}"
                        )
                        appManagerWrapper.installationIssue(app)
                    }
                }
            }.onFailure { throwable ->
                handleFailure(
                    throwable,
                    "INSTALL: Failed to reconcile startup state for ${app.name}/${app.packageName}"
                )
            }
        }
    }

    private fun hasActiveInstallSession(packageName: String): Boolean {
        return context.packageManager.packageInstaller.allSessions.any { session ->
            session.appPackageName == packageName && session.isActive
        }
    }
}
Loading