Loading app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +6 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -129,6 +133,8 @@ class AppLoungeApplication : Application(), Configuration.Provider { if (!isRunningUnderRobolectric()) { removeStalledInstallationFromDb() } installOrchestrator.init() } private fun isRunningUnderRobolectric(): Boolean = Build.FINGERPRINT == "robolectric" Loading app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt +4 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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> Loading app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +6 −9 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() Loading @@ -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() } Loading app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +9 −5 Original line number Diff line number Diff line Loading @@ -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)) { Loading @@ -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 } Loading app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt 0 → 100644 +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
app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +6 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -129,6 +133,8 @@ class AppLoungeApplication : Application(), Configuration.Provider { if (!isRunningUnderRobolectric()) { removeStalledInstallationFromDb() } installOrchestrator.init() } private fun isRunningUnderRobolectric(): Boolean = Build.FINGERPRINT == "robolectric" Loading
app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt +4 −0 Original line number Diff line number Diff line Loading @@ -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 { Loading @@ -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> Loading
app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +6 −9 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() Loading @@ -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() } Loading
app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +9 −5 Original line number Diff line number Diff line Loading @@ -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)) { Loading @@ -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 } Loading
app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt 0 → 100644 +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 } } }