From d5c1a8fbfca9e53c6b8a0f1d6c95687ec326f954 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 3 Mar 2026 16:18:34 +0600 Subject: [PATCH 1/8] 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. --- .../foundation/e/apps/AppLoungeApplication.kt | 5 + .../e/apps/data/install/AppInstallDAO.kt | 4 + .../workmanager/AppInstallProcessor.kt | 14 +- .../data/install/workmanager/InstallHelper.kt | 142 ++++++++++++++++++ .../install/workmanager/InstallWorkManager.kt | 46 ++++-- .../e/apps/ui/updates/UpdatesFragment.kt | 27 +++- .../installProcessor/FakeAppInstallDAO.kt | 6 + 7 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index 98b7056d4..69f95798d 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -77,6 +77,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 +125,8 @@ class AppLoungeApplication : Application(), Configuration.Provider { ) removeStalledInstallationFromDb() + + installHelper.init() } private fun removeStalledInstallationFromDb() = coroutineScope.launch { diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt index a435d7428..397f993cf 100644 --- a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt +++ b/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt @@ -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> + @Query("SELECT * FROM fuseddownload") + fun getDownloads(): Flow> + @Query("SELECT * FROM fuseddownload") suspend fun getDownloadList(): List diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt index 0e968c35b..4079a8489 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt @@ -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 } diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt new file mode 100644 index 000000000..4f196ac25 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt @@ -0,0 +1,142 @@ +/* + * 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 . + * + */ + +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 + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt index e3a453b6e..bbb41f1c7 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt @@ -1,30 +1,48 @@ package foundation.e.apps.data.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().setInputData( - Data.Builder() - .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, appInstall.id) - .putBoolean(InstallAppWorker.IS_UPDATE_WORK, isUpdateWork) - .build() - ).addTag(appInstall.id) - .build() - ) + 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() + + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .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" } diff --git a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt index fb22fe857..40956790a 100644 --- a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt @@ -200,16 +200,35 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI ) ).get() .none { it.state == WorkInfo.State.RUNNING } - val noInstallJobIsRunning = WorkManager.getInstance(requireContext()) - .getWorkInfosForUniqueWork(InstallWorkManager.INSTALL_WORK_NAME) - .get() - .none { listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING).contains(it.state) } + + val noInstallJobIsRunning = !hasActiveInstallWork() Timber.d("no update jobs are running : $noUpdateJobIsRunning") Timber.d("no install jobs are running : $noInstallJobIsRunning") setButtonEnabled(noUpdateJobIsRunning && noInstallJobIsRunning) } + private fun hasActiveInstallWork(): Boolean { + val workManager = WorkManager.getInstance(requireContext()) + val isActive: (WorkInfo) -> Boolean = { info -> + info.state == WorkInfo.State.ENQUEUED || info.state == WorkInfo.State.RUNNING + } + + // tag-based, per-package unique work names + val hasActiveTaggedInstall = workManager + .getWorkInfosByTag(InstallWorkManager.INSTALL_WORK_NAME) + .get() + .any(isActive) + + // single unique work name used by older app versions + val hasActiveLegacyInstall = workManager + .getWorkInfosForUniqueWork(InstallWorkManager.INSTALL_WORK_NAME) + .get() + .any(isActive) + + return hasActiveTaggedInstall || hasActiveLegacyInstall + } + private fun handleUpdateEvent(appEvent: AppEvent) { val event = appEvent.data as ResultSupreme.WorkError<*> when (event.data) { diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt index 93fc563e2..48749126c 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt @@ -23,6 +23,8 @@ import androidx.lifecycle.asLiveData import foundation.e.apps.data.enums.Status import foundation.e.apps.data.install.AppInstallDAO import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow class FakeAppInstallDAO : AppInstallDAO { @@ -36,6 +38,10 @@ class FakeAppInstallDAO : AppInstallDAO { TODO("Not yet implemented") } + override fun getDownloads(): Flow> { + return emptyFlow() + } + override suspend fun getDownloadList(): List { return appInstallList } -- GitLab From c7a98f2180ae8c09809874802c5486d5e9cad6b7 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 4 Mar 2026 15:57:01 +0600 Subject: [PATCH 2/8] test: add InstallHelper unit tests for reconciliation and enqueue flows --- .../install/workmanager/InstallHelperTest.kt | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt new file mode 100644 index 000000000..db177f07d --- /dev/null +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt @@ -0,0 +1,298 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.install.workmanager + +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.test.core.app.ApplicationProvider +import com.google.common.util.concurrent.Futures +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.util.MainCoroutineRule +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify as verifyMockito +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class InstallHelperTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val installDao = mock() + private val appManagerWrapper = mock() + + private var isInstallWorkManagerMocked = false + + @Before + fun setup() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + } + + @After + fun teardown() { + if (isInstallWorkManagerMocked) { + unmockkObject(InstallWorkManager) + isInstallWorkManagerMocked = false + } + } + + @Test + fun init_marksStaleDownloadAsInstallationIssue() = runTest { + val app = createAppInstall(status = Status.DOWNLOADING) + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(false) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper).installationIssue(app) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + } + + @Test + fun init_updatesStatusToInstalledWhenAppAlreadyInstalled() = runTest { + val app = createAppInstall(status = Status.DOWNLOADED) + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(true) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper).updateDownloadStatus(app, Status.INSTALLED) + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + + @Test + fun init_skipsReconciliationWhenInstallSessionIsActive() = runTest { + val app = createAppInstall(status = Status.INSTALLING) + val packageManager = mock() + val packageInstaller = mock() + val sessionInfo = mock() + val wrappedContext = object : ContextWrapper(context) { + override fun getPackageManager(): PackageManager = packageManager + } + + whenever(sessionInfo.appPackageName).thenReturn(app.packageName) + whenever(sessionInfo.isActive).thenReturn(true) + whenever(packageManager.packageInstaller).thenReturn(packageInstaller) + whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo)) + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val helper = InstallHelper(wrappedContext, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + } + + @Test + fun init_triggersAwaitingDownloadWhenNoActiveDownloadOrInstall() = runTest { + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } + } + + @Test + fun init_doesNotTriggerAwaitingWhenThereIsAnActiveDownload() = runTest { + val active = createAppInstall(id = "app.active", status = Status.DOWNLOADING) + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } + } + + @Test + fun init_reportsInstallationIssueWhenEnqueueFails() = runTest { + val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) + mockInstallWorkManagerFailure() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, times(1)).installationIssue(eq(awaiting)) + } + + @Test + fun init_skipsReconciliationWhenActiveWorkExists() = runTest { + val app = createAppInstall(id = "app.active.work", status = Status.DOWNLOADING) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(app.id) + .build() + WorkManager.getInstance(context).enqueue(request).result.get() + + whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + verifyMockito(appManagerWrapper, never()).updateDownloadStatus(any(), any()) + verifyMockito(appManagerWrapper, never()).isFusedDownloadInstalled(any()) + } + + @Test + fun init_doesNotReportIssueWhenEnqueueSucceeds() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.success", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + + @Test + fun init_continuesToObserveWhenReconciliationThrowsException() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.after.exception", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(installDao.getDownloads()) + .thenThrow(RuntimeException("reconcile failed")) + .thenReturn(flowOf(listOf(awaiting))) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } + } + + @Test + fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { + whenever(installDao.getDownloads()).thenThrow(CancellationException("cancel reconcile")) + + val helper = InstallHelper(context, this, appManagerWrapper, installDao) + + helper.init() + advanceUntilIdle() + + verifyMockito(installDao, times(1)).getDownloads() + } + + private fun mockInstallWorkManagerSuccess() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } returns successfulOperation() + } + + private fun mockInstallWorkManagerFailure() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } throws RuntimeException("enqueue failed") + } + + private fun successfulOperation(): Operation { + val operation = mock() + whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS)) + return operation + } + + private fun createAppInstall( + id: String = "app.id", + packageName: String = "foundation.e.app", + status: Status, + ) = AppInstall( + id = id, + name = id, + packageName = packageName, + status = status + ) + + class NoOpWorker( + appContext: Context, + workerParams: WorkerParameters + ) : Worker(appContext, workerParams) { + override fun doWork(): Result = Result.success() + } +} -- GitLab From af494839a38bf673e190b12708e27d809e9ca1cf Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 5 Mar 2026 13:22:34 +0600 Subject: [PATCH 3/8] refactor: make Update-all work request as expedited --- .../e/apps/data/install/updates/UpdatesWorkManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt index 348e1a99c..e4ff223bf 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt @@ -24,6 +24,7 @@ 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 timber.log.Timber @@ -46,6 +47,7 @@ object UpdatesWorkManager { private fun buildOneTimeWorkRequest(): OneTimeWorkRequest { return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply { setConstraints(buildWorkerConstraints()) + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) addTag(USER_TAG) }.setInputData( Data.Builder() -- GitLab From 40309c8d92ecdf9b4a6f218881a6c579bbfffc53 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 6 Mar 2026 16:34:07 +0600 Subject: [PATCH 4/8] refactor: fix import paths --- app/src/main/java/foundation/e/apps/AppLoungeApplication.kt | 1 + .../e/apps/data/install/workmanager/InstallHelper.kt | 4 ++-- .../e/apps/install/workmanager/InstallHelperTest.kt | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index 69f95798d..c66c3e357 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -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.InstallHelper import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.data.preference.AppLoungePreference diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt index 4f196ac25..b168c0e04 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt @@ -16,18 +16,18 @@ * */ -package foundation.e.apps.install.workmanager +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 foundation.e.apps.di.qualifiers.IoCoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.firstOrNull diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt index db177f07d..47d6f74cf 100644 --- a/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt @@ -35,6 +35,8 @@ 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.data.install.workmanager.InstallHelper +import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockkObject -- GitLab From 0a4ee2b4113a858d1c188c123f5f05da3b27f20a Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 10 Mar 2026 13:23:11 +0600 Subject: [PATCH 5/8] refactor: rename InstallHelper to InstallOrchestrator --- .../foundation/e/apps/AppLoungeApplication.kt | 6 +-- ...nstallHelper.kt => InstallOrchestrator.kt} | 2 +- ...lperTest.kt => InstallOrchestratorTest.kt} | 44 +++++++++---------- 3 files changed, 26 insertions(+), 26 deletions(-) rename app/src/main/java/foundation/e/apps/data/install/workmanager/{InstallHelper.kt => InstallOrchestrator.kt} (99%) rename app/src/test/java/foundation/e/apps/install/workmanager/{InstallHelperTest.kt => InstallOrchestratorTest.kt} (87%) diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index c66c3e357..acaa5d1a1 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -34,7 +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.InstallHelper +import foundation.e.apps.data.install.workmanager.InstallOrchestrator import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.data.preference.AppLoungePreference @@ -79,7 +79,7 @@ class AppLoungeApplication : Application(), Configuration.Provider { lateinit var coroutineScope: CoroutineScope @Inject - lateinit var installHelper: InstallHelper + lateinit var installOrchestrator: InstallOrchestrator @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate() { @@ -127,7 +127,7 @@ class AppLoungeApplication : Application(), Configuration.Provider { removeStalledInstallationFromDb() - installHelper.init() + installOrchestrator.init() } private fun removeStalledInstallationFromDb() = coroutineScope.launch { diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt similarity index 99% rename from app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt rename to app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt index b168c0e04..bcecded46 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallHelper.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt @@ -38,7 +38,7 @@ import timber.log.Timber import javax.inject.Inject @Suppress("TooGenericExceptionCaught") -class InstallHelper @Inject constructor( +class InstallOrchestrator @Inject constructor( @param:ApplicationContext val context: Context, @param:IoCoroutineScope private val scope: CoroutineScope, private val appManagerWrapper: AppManagerWrapper, diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt similarity index 87% rename from app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt rename to app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt index 47d6f74cf..820de4260 100644 --- a/app/src/test/java/foundation/e/apps/install/workmanager/InstallHelperTest.kt +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt @@ -35,7 +35,7 @@ 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.data.install.workmanager.InstallHelper +import foundation.e.apps.data.install.workmanager.InstallOrchestrator import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.util.MainCoroutineRule import io.mockk.every @@ -67,7 +67,7 @@ import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.N]) -class InstallHelperTest { +class InstallOrchestratorTest { @get:Rule val mainCoroutineRule = MainCoroutineRule() @@ -98,9 +98,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(false) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper).installationIssue(app) @@ -114,9 +114,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(true) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper).updateDownloadStatus(app, Status.INSTALLED) @@ -139,9 +139,9 @@ class InstallHelperTest { whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo)) whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val helper = InstallHelper(wrappedContext, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper, never()).installationIssue(any()) @@ -155,9 +155,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } @@ -172,9 +172,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()) .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any()) } @@ -187,9 +187,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper, times(1)).installationIssue(eq(awaiting)) @@ -206,9 +206,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper, never()).installationIssue(any()) @@ -223,9 +223,9 @@ class InstallHelperTest { whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(appManagerWrapper, never()).installationIssue(any()) @@ -240,9 +240,9 @@ class InstallHelperTest { .thenThrow(RuntimeException("reconcile failed")) .thenReturn(flowOf(listOf(awaiting))) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verify(exactly = 1) { InstallWorkManager.enqueueWork(awaiting, false) } @@ -252,9 +252,9 @@ class InstallHelperTest { fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { whenever(installDao.getDownloads()).thenThrow(CancellationException("cancel reconcile")) - val helper = InstallHelper(context, this, appManagerWrapper, installDao) + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) - helper.init() + installOrchestrator.init() advanceUntilIdle() verifyMockito(installDao, times(1)).getDownloads() -- GitLab From 76b77a770df2fdbe40e525ed3f32a4a72a1123a2 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 10 Mar 2026 14:27:00 +0600 Subject: [PATCH 6/8] test: add tests for InstallWorkManager --- .../workmanager/InstallWorkManagerTest.kt | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt diff --git a/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt new file mode 100644 index 000000000..a3746a4ef --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt @@ -0,0 +1,187 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.install.workmanager + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import androidx.work.impl.WorkManagerImpl +import androidx.work.impl.model.WorkSpec +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.install.models.AppInstall +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class InstallWorkManagerTest { + + private lateinit var context: Application + private lateinit var workManager: WorkManager + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + InstallWorkManager.context = context + workManager = WorkManager.getInstance(context) + workManager.cancelAllWork().result.get() + } + + @After + fun teardown() { + workManager.cancelAllWork().result.get() + Thread.interrupted() + } + + @Test + fun enqueueWork_buildsExpectedRequestForInstall() { + val appInstall = AppInstall(id = "id.install", packageName = "foundation.e.install") + + InstallWorkManager.enqueueWork(appInstall).result.get() + + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + val workInfo = workManager.getWorkInfosForUniqueWork(uniqueWorkName).get().single() + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).containsAtLeast(appInstall.id, InstallWorkManager.INSTALL_WORK_NAME) + assertThat(workSpec.input.getString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD)) + .isEqualTo(appInstall.id) + assertThat(workSpec.input.getBoolean(InstallAppWorker.IS_UPDATE_WORK, true)).isFalse() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + assertThat(workSpec.constraints.requiresStorageNotLow()).isTrue() + } + + @Test + fun enqueueWork_setsUpdateFlagWhenRequested() { + val appInstall = AppInstall(id = "id.update", packageName = "foundation.e.update") + + InstallWorkManager.enqueueWork(appInstall, isUpdateWork = true).result.get() + + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + val workInfo = workManager.getWorkInfosForUniqueWork(uniqueWorkName).get().single() + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workSpec.input.getBoolean(InstallAppWorker.IS_UPDATE_WORK, false)).isTrue() + } + + @Test + fun cancelWork_cancelsAllWorkByTag() { + val tag = "id.cancel" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + + InstallWorkManager.cancelWork(tag) + + val state = waitUntilFinished(request.id) + assertThat(state).isEqualTo(WorkInfo.State.CANCELLED) + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsTrueWhenActiveWorkExists() { + val tag = "id.active" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable(tag)).isTrue() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenOnlyFinishedWorkExists() { + val tag = "id.finished" + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(tag) + .build() + + workManager.enqueue(request).result.get() + InstallWorkManager.cancelWork(tag) + waitUntilFinished(request.id) + + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable(tag)).isFalse() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenNoWorkExistsForTag() { + assertThat(InstallWorkManager.checkWorkIsAlreadyAvailable("id.missing")).isFalse() + } + + @Test + fun checkWorkIsAlreadyAvailable_returnsFalseWhenThreadIsInterrupted() { + Thread.currentThread().interrupt() + + val result = InstallWorkManager.checkWorkIsAlreadyAvailable("id.interrupted") + + assertThat(result).isFalse() + assertThat(Thread.currentThread().isInterrupted).isFalse() + } + + @Test + fun getUniqueWorkName_returnsExpectedValueForNormalAndEmptyPackage() { + assertThat(InstallWorkManager.getUniqueWorkName("foundation.e.apps")) + .isEqualTo("${InstallWorkManager.INSTALL_WORK_NAME}/foundation.e.apps") + assertThat(InstallWorkManager.getUniqueWorkName("")) + .isEqualTo("${InstallWorkManager.INSTALL_WORK_NAME}/") + } + + private fun getWorkSpec(workId: UUID): WorkSpec { + val workManagerImpl = WorkManagerImpl.getInstance(context) + return requireNotNull(workManagerImpl.workDatabase.workSpecDao().getWorkSpec(workId.toString())) + } + + private fun waitUntilFinished(workId: UUID): WorkInfo.State { + repeat(30) { + val state = requireNotNull(workManager.getWorkInfoById(workId).get()).state + if (state.isFinished) { + return state + } + Thread.sleep(50) + } + return requireNotNull(workManager.getWorkInfoById(workId).get()).state + } + + class NoOpWorker( + appContext: Context, + workerParams: WorkerParameters + ) : Worker(appContext, workerParams) { + override fun doWork(): Result = Result.success() + } +} -- GitLab From 6de097eefb8ea7b487274dcfc04d54cf11361f42 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 10 Mar 2026 22:14:26 +0600 Subject: [PATCH 7/8] refactor: remove detekt's @Suppress("TooGenericExceptionCaught") from InstallOrchestrator Switched to Kotlin's run-catching{} with additional handling of CancellationException and throwables. --- .../workmanager/InstallOrchestrator.kt | 65 ++++++++++++------- .../workmanager/InstallOrchestratorTest.kt | 22 +++++++ 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt index bcecded46..62ed72ebf 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -@Suppress("TooGenericExceptionCaught") class InstallOrchestrator @Inject constructor( @param:ApplicationContext val context: Context, @param:IoCoroutineScope private val scope: CoroutineScope, @@ -47,12 +46,14 @@ class InstallOrchestrator @Inject constructor( fun init() { scope.launch { - try { + runCatching { cancelFailedDownloads() - } catch (cancellationException: CancellationException) { - throw cancellationException - } catch (e: Exception) { - Timber.e(e, "Failed to reconcile startup downloads") + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> Timber.e(throwable, "Failed to reconcile startup downloads") + else -> throw throwable + } } observeDownloads() } @@ -60,13 +61,17 @@ class InstallOrchestrator @Inject constructor( private fun observeDownloads() { installDao.getDownloads().onEach { list -> - try { + runCatching { 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") + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> Timber.e(throwable, "Failed to enqueue download worker") + else -> throw throwable + } } }.launchIn(scope) } @@ -74,18 +79,23 @@ class InstallOrchestrator @Inject constructor( private suspend fun trigger(download: AppInstall) { val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName) - try { + runCatching { 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) + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "INSTALL: Failed to enqueue unique work for ${download.name}: $uniqueWorkName" + ) + appManagerWrapper.installationIssue(download) + } + + else -> throw throwable + } } } @@ -106,7 +116,7 @@ class InstallOrchestrator @Inject constructor( Status.INSTALLING ) }.forEach { app -> - try { + runCatching { val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty() val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates } val hasActiveSession = @@ -125,11 +135,18 @@ class InstallOrchestrator @Inject constructor( appManagerWrapper.installationIssue(app) } } - } catch (e: Exception) { - Timber.e( - e, - "INSTALL: Failed to reconcile startup state for ${app.name}/${app.packageName}" - ) + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "INSTALL: Failed to reconcile startup state for ${app.name}/${app.packageName}" + ) + } + + else -> throw throwable + } } } } diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt index 820de4260..9a11fd42b 100644 --- a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt +++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt @@ -195,6 +195,21 @@ class InstallOrchestratorTest { verifyMockito(appManagerWrapper, times(1)).installationIssue(eq(awaiting)) } + @Test + fun init_doesNotReportIssueWhenEnqueueIsCancelled() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.cancelled", status = Status.AWAITING) + mockInstallWorkManagerCancellation() + + whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + + val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao) + + installOrchestrator.init() + advanceUntilIdle() + + verifyMockito(appManagerWrapper, never()).installationIssue(any()) + } + @Test fun init_skipsReconciliationWhenActiveWorkExists() = runTest { val app = createAppInstall(id = "app.active.work", status = Status.DOWNLOADING) @@ -274,6 +289,13 @@ class InstallOrchestratorTest { every { InstallWorkManager.enqueueWork(any(), any()) } throws RuntimeException("enqueue failed") } + private fun mockInstallWorkManagerCancellation() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any()) } throws CancellationException("enqueue cancelled") + } + private fun successfulOperation(): Operation { val operation = mock() whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS)) -- GitLab From 8a9adf9b83ccf2ef3a461c3b01b54fc55d25f952 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 11 Mar 2026 14:13:55 +0600 Subject: [PATCH 8/8] refactor: remove setRequiresBatteryNotLow(true) constraints from one-time expedited work for updates For expedited work requests, only network and storage constraints can be set; other constraints will throw an IllegalArgumentException. https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:work/work-runtime/src/main/java/androidx/work/WorkRequest.kt;l=303?q=WorkRequest. Added unit tests for UpdatesWorkManager. --- .../install/updates/UpdatesWorkManager.kt | 6 +- .../install/updates/UpdatesWorkManagerTest.kt | 134 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt index e4ff223bf..3031228d6 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorkManager.kt @@ -46,7 +46,11 @@ object UpdatesWorkManager { private fun buildOneTimeWorkRequest(): OneTimeWorkRequest { return OneTimeWorkRequest.Builder(UpdatesWorker::class.java).apply { - setConstraints(buildWorkerConstraints()) + setConstraints( + Constraints.Builder().apply { + setRequiredNetworkType(NetworkType.CONNECTED) + }.build() + ) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) addTag(USER_TAG) }.setInputData( diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt new file mode 100644 index 000000000..77d6f32e0 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkManagerTest.kt @@ -0,0 +1,134 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.install.updates + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.impl.WorkManagerImpl +import androidx.work.impl.model.WorkSpec +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +class UpdatesWorkManagerTest { + + private lateinit var context: Application + private lateinit var workManager: WorkManager + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + workManager = WorkManager.getInstance(context) + workManager.cancelAllWork().result.get() + } + + @After + fun teardown() { + workManager.cancelAllWork().result.get() + } + + @Test + fun startUpdateAllWork_buildsExpectedOneTimeRequest() { + UpdatesWorkManager.startUpdateAllWork(context) + + val workInfo = getActiveUniqueWorkInfo("updates_work_user") + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).contains(UpdatesWorkManager.USER_TAG) + assertThat(workSpec.input.getBoolean(UpdatesWorker.IS_AUTO_UPDATE, true)).isFalse() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.outOfQuotaPolicy) + .isEqualTo(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + + @Test + fun startUpdateAllWork_replacesExistingUniqueWork() { + UpdatesWorkManager.startUpdateAllWork(context) + val firstWorkId = getActiveUniqueWorkInfo("updates_work_user").id + + UpdatesWorkManager.startUpdateAllWork(context) + + val allWorkInfos = workManager.getWorkInfosForUniqueWork("updates_work_user").get() + val activeWorkInfos = allWorkInfos.filter { !it.state.isFinished } + + assertThat(activeWorkInfos).hasSize(1) + assertThat(activeWorkInfos.single().id).isNotEqualTo(firstWorkId) + } + + @Test + fun enqueueWork_buildsExpectedPeriodicRequest() { + UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.REPLACE) + + val workInfo = getActiveUniqueWorkInfo("updates_work") + val workSpec = getWorkSpec(workInfo.id) + + assertThat(workInfo.tags).contains(UpdatesWorkManager.TAG) + assertThat(workSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(6)) + assertThat(workSpec.constraints.requiresBatteryNotLow()).isTrue() + assertThat(workSpec.constraints.requiredNetworkType).isEqualTo(NetworkType.CONNECTED) + } + + @Test + fun enqueueWork_respectsExistingPeriodicWorkPolicy() { + UpdatesWorkManager.enqueueWork(context, interval = 6, ExistingPeriodicWorkPolicy.KEEP) + val initialWorkInfo = getActiveUniqueWorkInfo("updates_work") + val initialWorkSpec = getWorkSpec(initialWorkInfo.id) + + UpdatesWorkManager.enqueueWork(context, interval = 12, ExistingPeriodicWorkPolicy.KEEP) + + val keptWorkInfo = getActiveUniqueWorkInfo("updates_work") + val keptWorkSpec = getWorkSpec(keptWorkInfo.id) + assertThat(keptWorkInfo.id).isEqualTo(initialWorkInfo.id) + assertThat(keptWorkSpec.intervalDuration).isEqualTo(initialWorkSpec.intervalDuration) + + UpdatesWorkManager.enqueueWork(context, interval = 24, ExistingPeriodicWorkPolicy.REPLACE) + + val replacedWorkInfo = getActiveUniqueWorkInfo("updates_work") + val replacedWorkSpec = getWorkSpec(replacedWorkInfo.id) + assertThat(replacedWorkInfo.id).isNotEqualTo(initialWorkInfo.id) + assertThat(replacedWorkSpec.intervalDuration).isEqualTo(TimeUnit.HOURS.toMillis(24)) + } + + private fun getActiveUniqueWorkInfo(uniqueWorkName: String): WorkInfo { + return workManager.getWorkInfosForUniqueWork(uniqueWorkName).get() + .single { !it.state.isFinished } + } + + private fun getWorkSpec(workId: UUID): WorkSpec { + val workManagerImpl = WorkManagerImpl.getInstance(context) + return requireNotNull(workManagerImpl.workDatabase.workSpecDao().getWorkSpec(workId.toString())) + } +} -- GitLab