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

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

refactor: make app install work more reliable

Use WorkManager in a safer way so install jobs are less likely to get stuck when Android delays or interrupts background work.

This change moves normal install scheduling to a database-driven queue, uses one unique work name per package to avoid duplicate jobs, and adds startup cleanup for stale install states.

It also marks enqueue failures as installation issues so apps do not stay forever in queued/installing state.

The updates screen was adjusted to detect both the new and old install work patterns.
parent c0e99b88
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@ import foundation.e.apps.di.qualifiers.IoCoroutineScope
import foundation.e.apps.install.pkg.AppLoungePackageManager
import foundation.e.apps.install.pkg.PkgManagerBR
import foundation.e.apps.install.updates.UpdatesWorkManager
import foundation.e.apps.install.workmanager.InstallHelper
import foundation.e.apps.install.workmanager.InstallWorkManager
import foundation.e.apps.ui.setup.tos.TOS_VERSION
import foundation.e.apps.utils.CustomUncaughtExceptionHandler
@@ -77,6 +78,9 @@ class AppLoungeApplication : Application(), Configuration.Provider {
    @IoCoroutineScope
    lateinit var coroutineScope: CoroutineScope

    @Inject
    lateinit var installHelper: InstallHelper

    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    override fun onCreate() {
        super.onCreate()
@@ -122,6 +126,8 @@ class AppLoungeApplication : Application(), Configuration.Provider {
        )

        removeStalledInstallationFromDb()

        installHelper.init()
    }

    private fun removeStalledInstallationFromDb() = coroutineScope.launch {
+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>

+9 −5
Original line number Diff line number Diff line
@@ -129,6 +129,8 @@ class AppInstallProcessor @Inject constructor(
        isAnUpdate: Boolean = false,
        isSystemApp: Boolean = false
    ): Boolean {
        val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName)

        return try {
            val user = appLoungeDataStore.getUser()
            if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) {
@@ -141,13 +143,15 @@ class AppInstallProcessor @Inject constructor(
            if (!canEnqueue(appInstall)) return false

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

            // Use only for update work for now. For installation work, see InstallHelper#observeDownloads()
            if (isAnUpdate) {
                InstallWorkManager.enqueueWork(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
        }
+142 −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.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.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 foundation.e.apps.di.qualifiers.IoCoroutineScope
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

@Suppress("TooGenericExceptionCaught")
class InstallHelper @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 {
            try {
                cancelFailedDownloads()
            } catch (cancellationException: CancellationException) {
                throw cancellationException
            } catch (e: Exception) {
                Timber.e(e, "Failed to reconcile startup downloads")
            }
            observeDownloads()
        }
    }

    private fun observeDownloads() {
        installDao.getDownloads().onEach { list ->
            try {
                if (list.none { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }) {
                    list.find { it.status == Status.AWAITING }
                        ?.let { queuedDownload -> trigger(queuedDownload) }
                }
            } catch (e: Exception) {
                Timber.e(e, "Failed to enqueue download worker")
            }
        }.launchIn(scope)
    }

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

        try {
            val operation = InstallWorkManager.enqueueWork(download, false)
            operation.await()
            Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName")
        } catch (cancellationException: CancellationException) {
            throw cancellationException
        } catch (e: Exception) {
            Timber.e(
                e,
                "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 ->
            try {
                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)
                    }
                }
            } catch (e: Exception) {
                Timber.e(
                    e,
                    "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
        }
    }
}
+33 −13
Original line number Diff line number Diff line
package foundation.e.apps.install.workmanager

import android.app.Application
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import foundation.e.apps.data.install.models.AppInstall
import timber.log.Timber
import java.lang.Exception

object InstallWorkManager {
    const val INSTALL_WORK_NAME = "APP_LOUNGE_INSTALL_APP"
    lateinit var context: Application

    fun enqueueWork(appInstall: AppInstall, isUpdateWork: Boolean = false) {
        WorkManager.getInstance(context).enqueueUniqueWork(
            INSTALL_WORK_NAME,
            ExistingWorkPolicy.APPEND_OR_REPLACE,
            OneTimeWorkRequestBuilder<InstallAppWorker>().setInputData(
                Data.Builder()
    fun enqueueWork(appInstall: AppInstall, isUpdateWork: Boolean = false): Operation {
        val inputData = Data.Builder()
            .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, appInstall.id)
            .putBoolean(InstallAppWorker.IS_UPDATE_WORK, isUpdateWork)
            .build()
            ).addTag(appInstall.id)

        val constraints = Constraints.Builder()
            .setRequiresStorageNotLow(true)
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val request = OneTimeWorkRequestBuilder<InstallAppWorker>()
            .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
            .setInputData(inputData)
            .addTag(appInstall.id)
            .addTag(INSTALL_WORK_NAME)
            .setConstraints(constraints)
            .build()

        val operation = WorkManager.getInstance(context)
            .enqueueUniqueWork(
                getUniqueWorkName(appInstall.packageName),
                ExistingWorkPolicy.KEEP,
                request
            )

        return operation
    }

    fun cancelWork(tag: String) {
@@ -44,4 +62,6 @@ object InstallWorkManager {
        }
        return false
    }

    fun getUniqueWorkName(appPackageName: String): String = "${INSTALL_WORK_NAME}/$appPackageName"
}
Loading