From 67df168abdea4a3a49eadf99156ee3916d14f0a9 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 28 Apr 2026 17:36:51 +0600 Subject: [PATCH 1/7] feat(update): process update requests one by one Process enqueued update requests one by one only after the current installation finishes or fails. InstallOrchestrator manages both app installation and updates queue and allows only one item to process at a given moment. --- .../install/core/AppInstallationFacade.kt | 7 +- .../data/install/core/InstallationEnqueuer.kt | 16 +- .../data/install/core/InstallationRequest.kt | 5 +- .../helper/InstallationCompletionHandler.kt | 21 +- .../workmanager/InstallOrchestrator.kt | 82 ++++-- .../install/AppInstallDatabaseTest.kt | 2 +- .../workmanager/InstallOrchestratorTest.kt | 240 +++++++++++++++--- .../AppInstallationFacadeTest.kt | 5 +- .../InstallationCompletionHandlerTest.kt | 45 +++- .../InstallationEnqueuerTest.kt | 92 +++---- .../di/AppInstallPersistenceModule.kt | 1 + .../installation/local/AppInstallDatabase.kt | 13 +- .../data/installation/model/AppInstall.kt | 3 +- 13 files changed, 375 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt index 22b5de1d6..078fb2305 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt @@ -46,7 +46,8 @@ class AppInstallationFacade @Inject constructor( application: Application, isAnUpdate: Boolean = false ): Boolean { - val appInstall = installationRequest.create(application) + val isUpdate = isAnUpdate || application.status == Status.UPDATABLE + val appInstall = installationRequest.create(application, isUpdateRequest = isUpdate) if (application.source == Source.PLAY_STORE) { val libs = application.dependentLibraries.ifEmpty { @@ -66,10 +67,6 @@ class AppInstallationFacade @Inject constructor( appInstall.sharedLibs = libs } - val isUpdate = isAnUpdate || - application.status == Status.UPDATABLE || - appManager.isAppInstalled(appInstall) - return enqueueAppForInstallation(appInstall, isUpdate, application.isSystemApp) } diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt index 4e71cdcfb..00d7665ed 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt @@ -18,13 +18,10 @@ package foundation.e.apps.data.install.core -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.install.core.helper.PreEnqueueChecker -import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.preference.PlayStoreAuthStore @@ -34,7 +31,6 @@ import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject class InstallationEnqueuer @Inject constructor( - @ApplicationContext private val context: Context, private val preEnqueueChecker: PreEnqueueChecker, private val appManager: AppManager, private val sessionRepository: SessionRepository, @@ -55,11 +51,7 @@ class InstallationEnqueuer @Inject constructor( canEnqueue(appInstall, isAnUpdate) -> { appManager.updateAwaiting(appInstall) - // Enqueueing installation work is managed by InstallOrchestrator#observeDownloads(). - // This method only handles update work. - if (isAnUpdate) { - enqueueUpdate(appInstall) - } + // InstallOrchestrator owns WorkManager dispatch for queued installs and updates. true } @@ -101,12 +93,6 @@ class InstallationEnqueuer @Inject constructor( } } - private fun enqueueUpdate(appInstall: AppInstall) { - val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) - InstallWorkManager.enqueueWork(context, appInstall, true) - Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName") - } - suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean { return preEnqueueChecker.canEnqueue(appInstall, isAnUpdate) } diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt index ca0d0044b..16e4f67db 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt @@ -26,7 +26,7 @@ import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.InstallationType import javax.inject.Inject class InstallationRequest @Inject constructor() { - fun create(application: Application): AppInstall { + fun create(application: Application, isUpdateRequest: Boolean = false): AppInstall { val appInstall = AppInstall( application._id, application.source.toInstallationSource(), @@ -41,7 +41,8 @@ class InstallationRequest @Inject constructor() { application.latest_version_code, application.offer_type, application.isFree, - application.originalSize + application.originalSize, + isUpdateRequest = isUpdateRequest ).also { it.contentRating = application.contentRating } diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt index 6449c1f45..2356b974f 100644 --- a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt @@ -46,27 +46,26 @@ class InstallationCompletionHandler @Inject constructor( } override suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) { - if (!isUpdateWork) { + if (!isUpdateWork || appInstall?.isUpdateRequest != true) { return } - appInstall?.let { - val packageStatus = appManager.getInstallationStatus(appInstall) + val packageStatus = appManager.getInstallationStatus(appInstall) - if (packageStatus == Status.INSTALLED) { - UpdatesDao.addSuccessfullyUpdatedApp(it) - } + if (packageStatus == Status.INSTALLED) { + UpdatesDao.addSuccessfullyUpdatedApp(appInstall) + } - if (isUpdateCompleted()) { - showNotificationOnUpdateEnded() - UpdatesDao.clearSuccessfullyUpdatedApps() - } + if (isUpdateCompleted()) { + showNotificationOnUpdateEnded() + UpdatesDao.clearSuccessfullyUpdatedApps() } } private suspend fun isUpdateCompleted(): Boolean { val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter { - !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status) + it.isUpdateRequest && + !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status) } return UpdatesDao.successfulUpdatedApps.isNotEmpty() && downloadListWithoutAnyIssue.isEmpty() 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 8cde1a8e3..46cd54688 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 @@ -19,7 +19,6 @@ package foundation.e.apps.data.install.workmanager import android.content.Context -import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.await import dagger.hilt.android.qualifiers.ApplicationContext @@ -30,11 +29,16 @@ import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.domain.model.install.Status import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject class InstallOrchestrator @Inject constructor( @@ -43,31 +47,70 @@ class InstallOrchestrator @Inject constructor( private val appManager: AppManager, private val appInstallRepository: AppInstallRepository ) { + private val isInitialized = AtomicBoolean(false) + private val reevaluationRequests = Channel(Channel.CONFLATED) fun init() { + if (!isInitialized.compareAndSet(false, true)) return + scope.launch { runCatching { cancelFailedDownloads() }.onFailure { throwable -> handleFailure(throwable, "Failed to reconcile startup downloads") } + processQueueReevaluationRequests() observeDownloads() + observeInstallWork() } } private fun observeDownloads() { - appInstallRepository.getDownloads().onEach { list -> - 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") - } + appInstallRepository.getDownloads().onEach { + requestQueueReevaluation() }.launchIn(scope) } + private fun observeInstallWork() { + WorkManager.getInstance(context) + .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME) + .map { workInfos -> workInfos.any { !it.state.isFinished } } + .distinctUntilChanged() + .runningFold(false to false) { previous, isActive -> previous.second to isActive } + .onEach { (wasActive, isActive) -> + if (!wasActive || isActive) return@onEach + requestQueueReevaluation() + }.launchIn(scope) + } + + private fun processQueueReevaluationRequests() { + scope.launch { + while (reevaluationRequests.receiveCatching().isSuccess) { + reevaluateQueuedDownloads() + } + } + } + + private fun requestQueueReevaluation() { + reevaluationRequests.trySend(Unit) + } + + private suspend fun reevaluateQueuedDownloads() { + runCatching { + val downloads = appInstallRepository.getDownloadList() + val hasActiveDownloadOrInstall = + downloads.any { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING } + val hasActiveInstallWork = hasActiveInstallWork() + + if (!hasActiveDownloadOrInstall && !hasActiveInstallWork) { + downloads.find { it.status == Status.AWAITING } + ?.let { queuedDownload -> trigger(queuedDownload) } + } + }.onFailure { throwable -> + handleFailure(throwable, "Failed to enqueue download worker") + } + } + private fun handleFailure(throwable: Throwable, errorMessage: String) { when (throwable) { is CancellationException -> throw throwable @@ -80,7 +123,7 @@ class InstallOrchestrator @Inject constructor( val uniqueWorkName = InstallWorkManager.getUniqueWorkName(download.packageName) runCatching { - val operation = InstallWorkManager.enqueueWork(context, download, false) + val operation = InstallWorkManager.enqueueWork(context, download, download.isUpdateRequest) operation.await() Timber.d("INSTALL: Successfully enqueued unique work for ${download.name}: $uniqueWorkName") }.onFailure { throwable -> @@ -92,16 +135,19 @@ class InstallOrchestrator @Inject constructor( } } + private suspend fun hasActiveInstallWork(): Boolean { + val workInfos = WorkManager.getInstance(context) + .getWorkInfosByTagFlow(InstallWorkManager.INSTALL_WORK_NAME) + .firstOrNull() + .orEmpty() + + return workInfos.any { !it.state.isFinished } + } + private suspend fun cancelFailedDownloads() { val workManager = WorkManager.getInstance(context) val apps = appInstallRepository.getDownloads().firstOrNull().orEmpty() - val activeWorkStates = setOf( - WorkInfo.State.ENQUEUED, - WorkInfo.State.RUNNING, - WorkInfo.State.BLOCKED - ) - apps.filter { app -> app.status in listOf( Status.DOWNLOADING, @@ -111,7 +157,7 @@ class InstallOrchestrator @Inject constructor( }.forEach { app -> runCatching { val workInfos = workManager.getWorkInfosByTagFlow(app.id).firstOrNull().orEmpty() - val hasActiveWork = workInfos.any { info -> info.state in activeWorkStates } + val hasActiveWork = workInfos.any { info -> !info.state.isFinished } val hasActiveSession = app.status == Status.INSTALLING && hasActiveInstallSession(app.packageName) diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt index 05f309162..7ec61c143 100644 --- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt @@ -51,7 +51,7 @@ class AppInstallDatabaseTest { context, AppInstallDatabase::class.java, legacyDatabaseName - ).addMigrations(AppInstallDatabase.migration6To7) + ).addMigrations(AppInstallDatabase.migration6To7, AppInstallDatabase.migration7To8) .allowMainThreadQueries() .build() 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 c130a60a3..e6f81221d 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 @@ -23,30 +23,33 @@ import android.content.ContextWrapper import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.os.Build +import androidx.test.core.app.ApplicationProvider import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation +import androidx.work.WorkManager 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.application.AppManager -import foundation.e.apps.data.installation.model.AppInstall -import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.data.install.workmanager.InstallOrchestrator import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.data.installation.model.AppInstall +import foundation.e.apps.data.installation.repository.AppInstallRepository import foundation.e.apps.domain.model.install.Status 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.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow 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 @@ -58,11 +61,11 @@ 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 +import org.mockito.kotlin.verify as verifyMockito @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -77,6 +80,7 @@ class InstallOrchestratorTest { private val appManager = mock() private var isInstallWorkManagerMocked = false + private val orchestratorJobs = mutableListOf() @Before fun setup() { @@ -89,6 +93,8 @@ class InstallOrchestratorTest { unmockkObject(InstallWorkManager) isInstallWorkManagerMocked = false } + orchestratorJobs.forEach { it.cancel() } + orchestratorJobs.clear() WorkManagerTestInitHelper.closeWorkDatabase() } @@ -99,10 +105,10 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManager.isAppInstalled(app)).thenReturn(false) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager).reportInstallationIssue(app) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -115,10 +121,10 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) whenever(appManager.isAppInstalled(app)).thenReturn(true) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager).updateDownloadStatus(app, Status.INSTALLED) verifyMockito(appManager, never()).reportInstallationIssue(any()) @@ -140,10 +146,10 @@ class InstallOrchestratorTest { whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo)) whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(wrappedContext, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -155,15 +161,56 @@ class InstallOrchestratorTest { mockInstallWorkManagerSuccess() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } } + @Test + fun init_triggersOnlyFirstAwaitingDownloadWhenMultipleAreQueued() = runTest { + val firstAwaiting = createAppInstall(id = "app.awaiting.1", status = Status.AWAITING) + val secondAwaiting = createAppInstall(id = "app.awaiting.2", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(firstAwaiting, secondAwaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(firstAwaiting, secondAwaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, firstAwaiting, false) } + verify(exactly = 0) { InstallWorkManager.enqueueWork(context, secondAwaiting, false) } + } + + @Test + fun init_usesUpdateRequestFlagWhenTriggeringAwaitingDownload() = runTest { + val awaitingUpdate = createAppInstall( + id = "app.awaiting.update", + status = Status.AWAITING, + isUpdateRequest = true + ) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaitingUpdate))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaitingUpdate)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaitingUpdate, true) } + } + @Test fun init_doesNotTriggerAwaitingWhenThereIsAnActiveDownload() = runTest { val active = createAppInstall(id = "app.active", status = Status.DOWNLOADING) @@ -172,26 +219,109 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()) .thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(active, awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } } + @Test + fun init_doesNotTriggerAwaitingWhenActiveInstallWorkExists() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.active.work", status = Status.AWAITING) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(request).result.get() + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } + + @Test + fun init_waitsForFailedInstallWorkToBecomeInactiveBeforeTriggeringNextAwaitingDownload() = runTest { + val failed = createAppInstall(id = "app.failed", status = Status.INSTALLATION_ISSUE) + val awaiting = createAppInstall(id = "app.awaiting.after.failure", status = Status.AWAITING) + val activeInstallWork = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(activeInstallWork).result.get() + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(failed, awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(failed, awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + + WorkManager.getInstance(context).cancelWorkById(activeInstallWork.id).result.get() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } + } + + @Test + fun init_doesNotTriggerAwaitingDownloadTwiceWhenDbAndWorkManagerSignalsOverlap() = runTest { + val failed = createAppInstall(id = "app.failed.overlap", status = Status.INSTALLATION_ISSUE) + val awaiting = createAppInstall(id = "app.awaiting.overlap", status = Status.AWAITING) + val downloads = MutableSharedFlow>() + val activeInstallWork = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + WorkManager.getInstance(context).enqueue(activeInstallWork).result.get() + mockInstallWorkManagerDelayedEnqueue() + + whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), downloads) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(failed, awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + downloads.emit(listOf(failed, awaiting)) + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + + WorkManager.getInstance(context).cancelWorkById(activeInstallWork.id).result.get() + downloads.emit(listOf(failed, awaiting)) + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } + } + @Test fun init_reportsInstallationIssueWhenEnqueueFails() = runTest { val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING) mockInstallWorkManagerFailure() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, times(1)).reportInstallationIssue(eq(awaiting)) } @@ -202,11 +332,12 @@ class InstallOrchestratorTest { mockInstallWorkManagerCancellation() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) } @@ -222,10 +353,10 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow()) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) verifyMockito(appManager, never()).updateDownloadStatus(any(), any()) @@ -238,11 +369,12 @@ class InstallOrchestratorTest { mockInstallWorkManagerSuccess() whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appManager, never()).reportInstallationIssue(any()) } @@ -255,11 +387,12 @@ class InstallOrchestratorTest { whenever(appInstallRepository.getDownloads()) .thenThrow(RuntimeException("reconcile failed")) .thenReturn(flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } } @@ -268,14 +401,43 @@ class InstallOrchestratorTest { fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest { whenever(appInstallRepository.getDownloads()).thenThrow(CancellationException("cancel reconcile")) - val installOrchestrator = InstallOrchestrator(context, this, appManager, appInstallRepository) + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) installOrchestrator.init() - advanceUntilIdle() + advanceOrchestrator() verifyMockito(appInstallRepository, times(1)).getDownloads() } + @Test + fun init_isIdempotent() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.once", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()).thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 1) { InstallWorkManager.enqueueWork(context, awaiting, false) } + verifyMockito(appInstallRepository, times(2)).getDownloads() + } + + private fun createOrchestratorScope(): CoroutineScope { + val job = SupervisorJob() + orchestratorJobs += job + return CoroutineScope(mainCoroutineRule.testDispatcher + job) + } + + private fun advanceOrchestrator() { + mainCoroutineRule.testDispatcher.scheduler.advanceUntilIdle() + } + private fun mockInstallWorkManagerSuccess() { mockkObject(InstallWorkManager) isInstallWorkManagerMocked = true @@ -283,6 +445,22 @@ class InstallOrchestratorTest { every { InstallWorkManager.enqueueWork(any(), any(), any()) } returns successfulOperation() } + private fun mockInstallWorkManagerDelayedEnqueue() { + mockkObject(InstallWorkManager) + isInstallWorkManagerMocked = true + every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } + every { InstallWorkManager.enqueueWork(any(), any(), any()) } answers { + val appInstall = invocation.args[1] as AppInstall + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .addTag(appInstall.id) + .addTag(InstallWorkManager.INSTALL_WORK_NAME) + .build() + + WorkManager.getInstance(context).enqueue(request) + } + } + private fun mockInstallWorkManagerFailure() { mockkObject(InstallWorkManager) isInstallWorkManagerMocked = true @@ -307,11 +485,13 @@ class InstallOrchestratorTest { id: String = "app.id", packageName: String = "foundation.e.app", status: Status, + isUpdateRequest: Boolean = false, ) = AppInstall( id = id, name = id, packageName = packageName, - status = status + status = status, + isUpdateRequest = isUpdateRequest ) class NoOpWorker( diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt index af538bf61..dd1890dd7 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt @@ -90,8 +90,7 @@ class AppInstallationFacadeTest { dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) ) val appInstall = AppInstall(id = "123", packageName = "com.example.app") - coEvery { installationRequest.create(application) } returns appInstall - coEvery { appManager.isAppInstalled(appInstall) } returns false + coEvery { installationRequest.create(application, isUpdateRequest = true) } returns appInstall coEvery { installationEnqueuer.enqueue( appInstall, @@ -103,7 +102,7 @@ class AppInstallationFacadeTest { val result = appInstallationFacade.initAppInstall(application) assertTrue(result) - coVerify { installationRequest.create(application) } + coVerify { installationRequest.create(application, isUpdateRequest = true) } coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt index 0d35d459d..4a9d83de2 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt @@ -93,8 +93,15 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_tracksInstalledUpdates() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") - appInstallRepository.addDownload(AppInstall(id = "pending", status = Status.AWAITING, packageName = "com.example.pending")) + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending", + status = Status.AWAITING, + packageName = "com.example.pending", + isUpdateRequest = true + ) + ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED handler.onInstallFinished(appInstall, true) @@ -105,8 +112,10 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_sendsNotificationWhenUpdateBatchCompletes() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") - UpdatesDao.addSuccessfullyUpdatedApp(AppInstall(id = "existing", packageName = "com.example.existing")) + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + UpdatesDao.addSuccessfullyUpdatedApp( + AppInstall(id = "existing", packageName = "com.example.existing", isUpdateRequest = true) + ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED stubUpdateNotificationContext() @@ -118,19 +127,21 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_ignoresIssueAndPurchaseNeededStatusesForCompletion() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) appInstallRepository.addDownload( AppInstall( id = "issue", status = Status.INSTALLATION_ISSUE, - packageName = "com.example.issue" + packageName = "com.example.issue", + isUpdateRequest = true ) ) appInstallRepository.addDownload( AppInstall( id = "purchase", status = Status.PURCHASE_NEEDED, - packageName = "com.example.purchase" + packageName = "com.example.purchase", + isUpdateRequest = true ) ) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED @@ -143,7 +154,7 @@ class InstallationCompletionHandlerTest { @Test fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest { - val appInstall = AppInstall(id = "123", packageName = "com.example.app") + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED stubUpdateNotificationContext() @@ -152,6 +163,24 @@ class InstallationCompletionHandlerTest { assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) } + @Test + fun onInstallFinished_ignoresPendingNormalInstallsForUpdateCompletion() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending-normal", + status = Status.AWAITING, + packageName = "com.example.pending", + ) + ) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED + stubUpdateNotificationContext() + + handler.onInstallFinished(appInstall, true) + + verify { UpdatesNotifier.showNotification(context, "Update", "Updated message") } + } + private suspend fun stubUpdateNotificationContext() { val authData = AuthData(email = "user@example.com", isAnonymous = false).apply { locale = Locale.US diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt index bcae96a83..739561e9b 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt @@ -19,10 +19,8 @@ package foundation.e.apps.installProcessor import android.content.Context -import androidx.work.Operation import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.exceptions.InternalException -import com.google.common.util.concurrent.Futures import foundation.e.apps.R import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.application.ApplicationRepository @@ -34,7 +32,6 @@ import foundation.e.apps.data.install.core.helper.DevicePreconditions import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher import foundation.e.apps.data.install.core.helper.PreEnqueueChecker import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.InstallationSource import foundation.e.apps.data.installation.model.InstallationType @@ -49,7 +46,6 @@ import foundation.e.apps.domain.preferences.SessionRepository import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic @@ -115,7 +111,6 @@ class InstallationEnqueuerTest { devicePreconditions ) enqueuer = InstallationEnqueuer( - context, preEnqueueChecker, appManager, sessionRepository, playStoreAuthStore, @@ -190,70 +185,49 @@ class InstallationEnqueuerTest { fun enqueue_warnsAnonymousPaidUsersAndAborts() = runTest { val appInstall = createNativeInstall(isFree = false) - mockkObject(InstallWorkManager) - try { - coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS - coEvery { - playStoreAuthStore.awaitAuthData() - } returns AuthData(email = "anon@example.com", isAnonymous = true) - coEvery { appManager.addDownload(appInstall) } returns true - coEvery { ageLimiter.allow(appInstall) } returns true - every { context.isNetworkAvailable() } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } - - val result = enqueuer.enqueue(appInstall) - - assertFalse(result) - assertTrue(appEventDispatcher.events.any { - it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message - }) - coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(InstallWorkManager) - } + coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS + coEvery { + playStoreAuthStore.awaitAuthData() + } returns AuthData(email = "anon@example.com", isAnonymous = true) + coEvery { appManager.addDownload(appInstall) } returns true + coEvery { ageLimiter.allow(appInstall) } returns true + every { context.isNetworkAvailable() } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = enqueuer.enqueue(appInstall) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { + it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message + }) + coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } } @Test fun enqueue_returnsFalseWhenCanEnqueueFails() = runTest { val appInstall = createPwaInstall() - mockkObject(InstallWorkManager) - try { - coEvery { appManager.addDownload(appInstall) } returns false + coEvery { appManager.addDownload(appInstall) } returns false - val result = enqueuer.enqueue(appInstall, isAnUpdate = true) + val result = enqueuer.enqueue(appInstall, isAnUpdate = true) - assertFalse(result) - coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(InstallWorkManager) - } + assertFalse(result) + coVerify(exactly = 0) { appManager.updateAwaiting(appInstall) } } @Test - fun enqueue_enqueuesUpdateWorkWhenUpdateAndChecksPass() = runTest { + fun enqueue_marksUpdateAwaitingWhenUpdateAndChecksPass() = runTest { val appInstall = createPwaInstall() - mockkObject(InstallWorkManager) - try { - coEvery { appManager.addDownload(appInstall) } returns true - coEvery { ageLimiter.allow(appInstall) } returns true - every { context.isNetworkAvailable() } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - every { InstallWorkManager.getUniqueWorkName(appInstall.packageName) } answers { callOriginal() } - every { InstallWorkManager.enqueueWork(context, appInstall, true) } returns successfulOperation() - - val result = enqueuer.enqueue(appInstall, isAnUpdate = true) - - assertTrue(result) - coVerify { appManager.updateAwaiting(appInstall) } - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } - } finally { - unmockkObject(InstallWorkManager) - } + coEvery { appManager.addDownload(appInstall) } returns true + coEvery { ageLimiter.allow(appInstall) } returns true + every { context.isNetworkAvailable() } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L + + val result = enqueuer.enqueue(appInstall, isAnUpdate = true) + + assertTrue(result) + coVerify { appManager.updateAwaiting(appInstall) } } @Test @@ -471,12 +445,6 @@ class InstallationEnqueuerTest { coVerify(exactly = 0) { appManager.addDownload(appInstall) } } - private fun successfulOperation(): Operation { - val operation = mockk() - every { operation.result } returns Futures.immediateFuture(Operation.SUCCESS) - return operation - } - private fun createPwaInstall(isFree: Boolean = true) = AppInstall( type = InstallationType.PWA, id = "123", diff --git a/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt index 06cc79395..83555c84d 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt @@ -39,6 +39,7 @@ object AppInstallPersistenceModule { fun provideDatabaseInstance(@ApplicationContext context: Context): AppInstallDatabase { return Room.databaseBuilder(context, AppInstallDatabase::class.java, DATABASE_NAME) .fallbackToDestructiveMigration() + .addMigrations(AppInstallDatabase.migration6To7, AppInstallDatabase.migration7To8) .build() } diff --git a/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt index 225ccb9ae..1740eab74 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt @@ -25,7 +25,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import foundation.e.apps.data.installation.model.AppInstall -@Database(entities = [AppInstall::class], version = 7, exportSchema = false) +@Database(entities = [AppInstall::class], version = 8, exportSchema = false) @TypeConverters(AppInstallConverter::class) abstract class AppInstallDatabase : RoomDatabase() { abstract fun fusedDownloadDao(): AppInstallDAO @@ -38,5 +38,16 @@ abstract class AppInstallDatabase : RoomDatabase() { ) } } + + val migration7To8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE FusedDownload ADD COLUMN isUpdateRequest INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "UPDATE FusedDownload SET isUpdateRequest = 1 WHERE orgStatus = 'UPDATABLE'" + ) + } + } } } diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt index d08334250..1fd753e82 100644 --- a/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt +++ b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt @@ -44,7 +44,8 @@ data class AppInstall( var files: List = mutableListOf(), var signature: String = String(), var contentRating: ContentRating = ContentRating(), - var sharedLibs: List = emptyList() + var sharedLibs: List = emptyList(), + val isUpdateRequest: Boolean = false ) { @Ignore private val installingStatusList = listOf( -- GitLab From c41932f396ce038ef2885397e98bfc24de1c5d58 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Tue, 28 Apr 2026 18:14:37 +0600 Subject: [PATCH 2/7] test(update): cover serialized update queueing Add migration and queueing regressions for persisted update requests, serialized dispatch, and update-only completion handling. --- .../install/AppInstallDatabaseTest.kt | 157 ++++++++++++++++++ .../AppInstallationFacadeTest.kt | 59 ++++++- .../InstallationCompletionHandlerTest.kt | 11 ++ .../InstallationEnqueuerTest.kt | 21 ++- .../InstallationRequestTest.kt | 4 +- 5 files changed, 243 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt index 7ec61c143..1171517dd 100644 --- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt +++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallDatabaseTest.kt @@ -88,6 +88,97 @@ class AppInstallDatabaseTest { } } + @Test + fun migration7To8_opensVersion7DatabaseAndAddsIsUpdateRequestColumn() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName) + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("PRAGMA table_info(`FusedDownload`)") + .use { cursor -> + var foundIsUpdateRequestColumn = false + while (cursor.moveToNext()) { + if (cursor.getString(cursor.getColumnIndexOrThrow("name")) != "isUpdateRequest") { + continue + } + + foundIsUpdateRequestColumn = true + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("type"))) + .isEqualTo("INTEGER") + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("notnull"))) + .isEqualTo(1) + assertThat(cursor.getString(cursor.getColumnIndexOrThrow("dflt_value"))) + .isEqualTo("0") + } + + assertThat(foundIsUpdateRequestColumn).isTrue() + } + } finally { + migratedDatabase.close() + } + } + + @Test + fun migration7To8_keepsExistingNonUpdateRowsAsNotUpdateRequests() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName, orgStatus = "AWAITING") + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("SELECT `isUpdateRequest` FROM `FusedDownload` WHERE `id` = 'legacy-id'") + .use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("isUpdateRequest"))) + .isEqualTo(0) + } + } finally { + migratedDatabase.close() + } + } + + @Test + fun migration7To8_backfillsQueuedUpdateRowsAsUpdateRequests() { + val legacyDatabaseName = trackDatabase("app_install_test_${System.nanoTime()}") + createVersion7Database(legacyDatabaseName, orgStatus = "UPDATABLE") + + val migratedDatabase = Room.databaseBuilder( + context, + AppInstallDatabase::class.java, + legacyDatabaseName + ).addMigrations(AppInstallDatabase.migration7To8) + .allowMainThreadQueries() + .build() + + try { + migratedDatabase.openHelper.writableDatabase + .query("SELECT `isUpdateRequest` FROM `FusedDownload` WHERE `id` = 'legacy-id'") + .use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getInt(cursor.getColumnIndexOrThrow("isUpdateRequest"))) + .isEqualTo(1) + } + } finally { + migratedDatabase.close() + } + } + private fun createVersion6Database(databaseName: String) { context.deleteDatabase(databaseName) val databaseFile = context.getDatabasePath(databaseName) @@ -130,6 +221,50 @@ class AppInstallDatabaseTest { } } + private fun createVersion7Database(databaseName: String, orgStatus: String = "AWAITING") { + context.deleteDatabase(databaseName) + val databaseFile = context.getDatabasePath(databaseName) + databaseFile.parentFile?.mkdirs() + + val database = SQLiteDatabase.openOrCreateDatabase(databaseFile, null) + try { + database.execSQL(VERSION_7_CREATE_TABLE_SQL) + database.execSQL( + """ + INSERT INTO `FusedDownload` ( + `id`, `source`, `status`, `name`, `packageName`, `downloadURLList`, + `downloadIdMap`, `orgStatus`, `type`, `iconImageUrl`, `versionCode`, + `offerType`, `isFree`, `appSize`, `files`, `signature`, `contentRating`, + `sharedLibs` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + "legacy-id", + "PLAY_STORE", + "AWAITING", + "Legacy App", + "com.example.legacy", + "[]", + "{}", + orgStatus, + "NATIVE", + "icon.png", + 123L, + 0, + 1, + 2048L, + "[]", + "legacy-signature", + "{}", + "[]" + ) + ) + database.version = 7 + } finally { + database.close() + } + } + private fun trackDatabase(name: String): String { databasesToDelete += name return name @@ -156,5 +291,27 @@ class AppInstallDatabaseTest { "`signature` TEXT NOT NULL, " + "`contentRating` TEXT NOT NULL, " + "PRIMARY KEY(`id`))" + + private const val VERSION_7_CREATE_TABLE_SQL = + "CREATE TABLE IF NOT EXISTS `FusedDownload` (" + + "`id` TEXT NOT NULL, " + + "`source` TEXT NOT NULL, " + + "`status` TEXT NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`packageName` TEXT NOT NULL, " + + "`downloadURLList` TEXT NOT NULL, " + + "`downloadIdMap` TEXT NOT NULL, " + + "`orgStatus` TEXT NOT NULL, " + + "`type` TEXT NOT NULL, " + + "`iconImageUrl` TEXT NOT NULL, " + + "`versionCode` INTEGER NOT NULL, " + + "`offerType` INTEGER NOT NULL, " + + "`isFree` INTEGER NOT NULL, " + + "`appSize` INTEGER NOT NULL, " + + "`files` TEXT NOT NULL, " + + "`signature` TEXT NOT NULL, " + + "`contentRating` TEXT NOT NULL, " + + "`sharedLibs` TEXT NOT NULL, " + + "PRIMARY KEY(`id`))" } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt index dd1890dd7..39d68f19a 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt @@ -79,7 +79,7 @@ class AppInstallationFacadeTest { } @Test - fun initAppInstall_computesUpdateFlagAndDelegates() = runTest { + fun initAppInstall_marksUpdatableAppsAsUpdateRequests() = runTest { val application = Application( _id = "123", source = Source.PLAY_STORE, @@ -106,6 +106,63 @@ class AppInstallationFacadeTest { coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } } + @Test + fun initAppInstall_marksExplicitUpdatesAsUpdateRequests() = runTest { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.INSTALLED, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE, + dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationRequest.create(application, isUpdateRequest = true) } returns appInstall + coEvery { + installationEnqueuer.enqueue( + appInstall, + true, + application.isSystemApp + ) + } returns true + + val result = appInstallationFacade.initAppInstall(application, isAnUpdate = true) + + assertTrue(result) + coVerify { installationRequest.create(application, isUpdateRequest = true) } + coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } + } + + @Test + fun initAppInstall_doesNotMarkInstalledAppsAsUpdateRequestsFromInstallState() = runTest { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.INSTALLED, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE, + dependentLibraries = listOf(SharedLib(packageName = "com.example.lib", versionCode = 1L)) + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationRequest.create(application, isUpdateRequest = false) } returns appInstall + coEvery { + installationEnqueuer.enqueue( + appInstall, + false, + application.isSystemApp + ) + } returns true + + val result = appInstallationFacade.initAppInstall(application) + + assertTrue(result) + coVerify { installationRequest.create(application, isUpdateRequest = false) } + coVerify { installationEnqueuer.enqueue(appInstall, false, application.isSystemApp) } + coVerify(exactly = 0) { appManager.isAppInstalled(any()) } + } + @Test fun enqueueAppForInstallation_delegatesResult() = runTest { val appInstall = AppInstall(id = "123", packageName = "com.example.app") diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt index 4a9d83de2..aaffc3f0d 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt @@ -91,6 +91,17 @@ class InstallationCompletionHandlerTest { verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } } + @Test + fun onInstallFinished_ignoresNonUpdateRequestsEvenWhenUpdateWorkIsTrue() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + + handler.onInstallFinished(appInstall, true) + + verify(exactly = 0) { appInstallationManager.getInstallationStatus(any()) } + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + @Test fun onInstallFinished_tracksInstalledUpdates() = runTest { val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt index 739561e9b..90281d1b1 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt @@ -32,6 +32,7 @@ import foundation.e.apps.data.install.core.helper.DevicePreconditions import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher import foundation.e.apps.data.install.core.helper.PreEnqueueChecker import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.installation.model.InstallationSource import foundation.e.apps.data.installation.model.InstallationType @@ -219,15 +220,21 @@ class InstallationEnqueuerTest { fun enqueue_marksUpdateAwaitingWhenUpdateAndChecksPass() = runTest { val appInstall = createPwaInstall() - coEvery { appManager.addDownload(appInstall) } returns true - coEvery { ageLimiter.allow(appInstall) } returns true - every { context.isNetworkAvailable() } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L + mockkObject(InstallWorkManager) + try { + coEvery { appManager.addDownload(appInstall) } returns true + coEvery { ageLimiter.allow(appInstall) } returns true + every { context.isNetworkAvailable() } returns true + every { StorageComputer.spaceMissing(appInstall) } returns 0L - val result = enqueuer.enqueue(appInstall, isAnUpdate = true) + val result = enqueuer.enqueue(appInstall, isAnUpdate = true) - assertTrue(result) - coVerify { appManager.updateAwaiting(appInstall) } + assertTrue(result) + coVerify { appManager.updateAwaiting(appInstall) } + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } finally { + unmockkObject(InstallWorkManager) + } } @Test diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt index a660b6ac6..9629d2924 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt @@ -55,7 +55,7 @@ class InstallationRequestTest { originalSize = 2048L ) - val appInstall = installationRequest.create(application) + val appInstall = installationRequest.create(application, isUpdateRequest = true) assertEquals("123", appInstall.id) assertEquals(InstallationSource.PLAY_STORE, appInstall.source) @@ -68,6 +68,8 @@ class InstallationRequestTest { assertEquals(1, appInstall.offerType) assertEquals(false, appInstall.isFree) assertEquals(2048L, appInstall.appSize) + assertEquals(Status.AWAITING, appInstall.orgStatus) + assertTrue(appInstall.isUpdateRequest) } @Test -- GitLab From 9f77b3287cf4f16048db212f9b8380657d8c1a17 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 30 Apr 2026 15:27:37 +0600 Subject: [PATCH 3/7] fix(updates): fix ConcurrentModificationException while updating apps Prevent concurrent modification during update installs and when exposing DAO-backed update lists. --- .../e/apps/data/application/UpdatesDao.kt | 6 +- .../data/install/updates/UpdatesWorker.kt | 3 +- .../e/apps/data/application/UpdatesDaoTest.kt | 73 +++++++++++++++++++ .../data/install/updates/UpdatesWorkerTest.kt | 43 +++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt index ea5029841..316338634 100644 --- a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt +++ b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt @@ -22,12 +22,14 @@ import foundation.e.apps.data.installation.model.AppInstall object UpdatesDao { private val _appsAwaitingForUpdate: MutableList = mutableListOf() - val appsAwaitingForUpdate: List = _appsAwaitingForUpdate + val appsAwaitingForUpdate: List + get() = _appsAwaitingForUpdate.toList() var appsAwaitingForUpdateIncludesOtherStores: Boolean = false private set private val _successfulUpdatedApps = mutableListOf() - val successfulUpdatedApps: List = _successfulUpdatedApps + val successfulUpdatedApps: List + get() = _successfulUpdatedApps.toList() fun addItemsForUpdate( appsNeedUpdate: List, diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt index 36c0dcab6..af017400c 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt @@ -214,7 +214,8 @@ class UpdatesWorker @AssistedInject constructor( val authData = playStoreAuthManager.getValidatedAuthData() val isNotLoggedIntoPersonalAccount = !authData.isValidData() || authData.data?.isAnonymous == true - for (fusedApp in appsNeededToUpdate) { + val appsToUpdate = appsNeededToUpdate.toList() + for (fusedApp in appsToUpdate) { val shouldSkip = (!fusedApp.isFree && isNotLoggedIntoPersonalAccount) if (shouldSkip.or(isStopped)) { // respect the stop signal as well response.add(Pair(fusedApp, false)) diff --git a/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt new file mode 100644 index 000000000..df6dc0dc1 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt @@ -0,0 +1,73 @@ +/* + * 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.application + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.installation.model.AppInstall +import org.junit.After +import org.junit.Before +import org.junit.Test + +class UpdatesDaoTest { + + @Before + fun setUp() { + resetDao() + } + + @After + fun tearDown() { + resetDao() + } + + @Test + fun appsAwaitingForUpdate_returnsSnapshot() { + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + + UpdatesDao.addItemsForUpdate(listOf(firstApp, secondApp)) + + val snapshot = UpdatesDao.appsAwaitingForUpdate + + UpdatesDao.removeUpdateIfExists(secondApp.package_name) + + assertThat(snapshot).containsExactly(firstApp, secondApp).inOrder() + } + + @Test + fun successfulUpdatedApps_returnsSnapshot() { + val firstInstall = AppInstall(id = "1", packageName = "app.one") + val secondInstall = AppInstall(id = "2", packageName = "app.two") + + UpdatesDao.addSuccessfullyUpdatedApp(firstInstall) + UpdatesDao.addSuccessfullyUpdatedApp(secondInstall) + + val snapshot = UpdatesDao.successfulUpdatedApps + + UpdatesDao.clearSuccessfullyUpdatedApps() + + assertThat(snapshot).containsExactly(firstInstall, secondInstall).inOrder() + } + + private fun resetDao() { + UpdatesDao.addItemsForUpdate(emptyList()) + UpdatesDao.clearSuccessfullyUpdatedApps() + } +} diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 5826fe3cf..2afb16baa 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -834,6 +835,48 @@ class UpdatesWorkerTest { verify(appInstallationFacade, times(1)).initAppInstall(paidApp, true) } + @Test + fun startUpdateProcess_usesSnapshotWhenInstallMutatesInputList() = runTest { + val workerContext = mock() + val params = mock() + val updatesManagerRepository = mock() + val appLoungeDataStore = createDataStore() + val authData = AuthData(email = "user@example.com", isAnonymous = false) + val playStoreAuthManager = createPlayStoreAuthManager(ResultSupreme.Success(authData)) + val appInstallationFacade = mock() + val blockedAppRepository = mock() + val systemAppsUpdatesRepository = mock() + + val worker = createWorker( + workerContext, + params, + updatesManagerRepository, + appLoungeDataStore, + playStoreAuthManager, + appInstallationFacade, + blockedAppRepository, + systemAppsUpdatesRepository + ) + + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + val appsNeededToUpdate = mutableListOf(firstApp, secondApp) + + doAnswer { + appsNeededToUpdate.remove(secondApp) + true + }.whenever(appInstallationFacade).initAppInstall(firstApp, true) + whenever(appInstallationFacade.initAppInstall(secondApp, true)).thenReturn(false) + + val result = runCatching { worker.startUpdateProcess(appsNeededToUpdate) } + + assertThat(result.exceptionOrNull()).isNull() + assertThat(result.getOrThrow()).containsExactly( + Pair(firstApp, true), + Pair(secondApp, false) + ).inOrder() + } + @Test fun triggerUpdateProcessOnSettings_skipsWhenAutoInstallDisabled() = runTest { val workerContext = mock() -- GitLab From b76718ee410ac952b7cefa41423dee990906aabd Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 30 Apr 2026 20:50:49 +0600 Subject: [PATCH 4/7] fix(updates): restore install button when download progress tracking ends Treat missing download progress as no active update so the updates list can restore the install button instead of leaving items stuck in a download state. --- .../data/install/DownloadProgressTracker.kt | 4 ++-- .../ApplicationListRVAdapter.kt | 24 ++++++++++++------- .../e/apps/ui/updates/UpdatesFragment.kt | 21 ++++++++++++++++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt b/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt index ee9eac82c..c063087f1 100644 --- a/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/DownloadProgressTracker.kt @@ -42,10 +42,10 @@ class DownloadProgressTracker @Inject constructor( application?.let { app -> val appDownload = appManager.getDownloadList() .singleOrNull { it.id.contentEquals(app._id) && it.packageName.contentEquals(app.package_name) } - ?: return 0 + ?: return -1 return calculateProgress(appDownload, progress) } - return 0 + return -1 } suspend fun calculateProgress( diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 4ac4c7060..ddae3c1a8 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -144,17 +144,9 @@ class ApplicationListRVAdapter( updateRating(searchApp) updateSourceTag(searchApp) setAppIcon(searchApp, shimmerDrawable) - removeIsPurchasedObserver(holder) setInstallButtonDimensions(view) - - if (appInfoFetchViewModel.isAppInBlockedList(searchApp)) { - setupShowMoreButton() - } else { - mainActivityViewModel.verifyUiFilter(searchApp) { - setupInstallButton(searchApp, view, holder) - } - } + bindInstallControls(holder, searchApp) } } @@ -169,6 +161,20 @@ class ApplicationListRVAdapter( } } + fun bindInstallControls(holder: ViewHolder, searchApp: Application = holder.app) { + holder.app = searchApp + removeIsPurchasedObserver(holder) + with(holder.binding) { + if (appInfoFetchViewModel.isAppInBlockedList(searchApp)) { + setupShowMoreButton() + } else { + mainActivityViewModel.verifyUiFilter(searchApp) { + setupInstallButton(searchApp, holder.itemView, holder) + } + } + } + } + companion object { private const val SHIMMER_DURATION_MS = 500L private const val SHIMMER_BASE_ALPHA = 0.7f 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 0f2465ea6..8951d1726 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 @@ -440,6 +440,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) if (progress == -1) { + restoreInstallButton(adapter, recyclerView, index, fusedApp) return@forEachIndexed } @@ -452,6 +453,26 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } } + private fun restoreInstallButton( + adapter: ApplicationListRVAdapter, + recyclerView: RecyclerView, + index: Int, + fusedApp: Application + ) { + val restoredApp = fusedApp.copy() + mainActivityViewModel.updateStatusOfFusedApps( + applicationList = listOf(restoredApp), + appInstallList = emptyList() + ) + displayedUpdates.find { it._id == restoredApp._id }?.status = restoredApp.status + updateButtonAvailability() + + val viewHolder = recyclerView.findViewHolderForAdapterPosition(index) + as? ApplicationListRVAdapter.ViewHolder + ?: return + adapter.bindInstallControls(viewHolder, restoredApp) + } + override fun onDestroyView() { resetViewState() updatesListAdapter = null -- GitLab From 47885f0f9dec6d377a7a0342240595008d94d1e5 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 1 May 2026 17:56:49 +0600 Subject: [PATCH 5/7] test(updates): add unit tests for snapshot safety, edge cases, and error handling - UpdatesDao: verify snapshots remain stable during concurrent modifications - DownloadProgressTracker: cover null/missing app scenarios - InstallOrchestrator: test graceful handling of repository errors - InstallationCompletionHandler: validate null input and non-installed status guards --- .../e/apps/data/application/UpdatesDaoTest.kt | 35 +++++++++++++++++ .../install/DownloadProgressTrackerTest.kt | 35 +++++++++++++++++ .../workmanager/InstallOrchestratorTest.kt | 19 +++++++++ .../InstallationCompletionHandlerTest.kt | 39 +++++++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt index df6dc0dc1..0b4cfcc46 100644 --- a/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt +++ b/app/src/test/java/foundation/e/apps/data/application/UpdatesDaoTest.kt @@ -66,6 +66,41 @@ class UpdatesDaoTest { assertThat(snapshot).containsExactly(firstInstall, secondInstall).inOrder() } + @Test + fun appsAwaitingForUpdate_snapshotIsSafeToIterateWhileDaoIsModified() { + val firstApp = Application(name = "App1", package_name = "app.one") + val secondApp = Application(name = "App2", package_name = "app.two") + val thirdApp = Application(name = "App3", package_name = "app.three") + + UpdatesDao.addItemsForUpdate(listOf(firstApp, secondApp)) + + val snapshot = UpdatesDao.appsAwaitingForUpdate + + UpdatesDao.addItemsForUpdate(listOf(thirdApp)) + UpdatesDao.removeUpdateIfExists(firstApp.package_name) + + val items = snapshot.map { it.package_name } + assertThat(items).containsExactly("app.one", "app.two").inOrder() + } + + @Test + fun successfulUpdatedApps_snapshotIsSafeToIterateWhileDaoIsModified() { + val firstInstall = AppInstall(id = "1", packageName = "app.one") + val secondInstall = AppInstall(id = "2", packageName = "app.two") + val thirdInstall = AppInstall(id = "3", packageName = "app.three") + + UpdatesDao.addSuccessfullyUpdatedApp(firstInstall) + UpdatesDao.addSuccessfullyUpdatedApp(secondInstall) + + val snapshot = UpdatesDao.successfulUpdatedApps + + UpdatesDao.addSuccessfullyUpdatedApp(thirdInstall) + UpdatesDao.clearSuccessfullyUpdatedApps() + + val items = snapshot.map { it.packageName } + assertThat(items).containsExactly("app.one", "app.two").inOrder() + } + private fun resetDao() { UpdatesDao.addItemsForUpdate(emptyList()) UpdatesDao.clearSuccessfullyUpdatedApps() diff --git a/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt b/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt index 8723e26a9..a2426adf2 100644 --- a/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/DownloadProgressTrackerTest.kt @@ -21,6 +21,10 @@ import foundation.e.apps.data.application.AppManager import foundation.e.apps.data.install.download.data.DownloadProgress import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -84,4 +88,35 @@ class DownloadProgressTrackerTest { assertEquals(100, percent) } + + @Test + fun calculateProgress_appNotFound_returnsMinusOne() = runTest { + val application = Application( + _id = "missing-id", + package_name = "com.missing.app", + source = Source.PLAY_STORE, + type = Type.NATIVE + ) + coEvery { appManager.getDownloadList() } returns emptyList() + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val result = downloadProgressTracker.calculateProgress(application, progress) + + assertEquals(-1, result) + } + + @Test + fun calculateProgress_nullApplication_returnsMinusOne() = runTest { + val progress = DownloadProgress( + totalSizeBytes = mutableMapOf(1L to 100L), + bytesDownloadedSoFar = mutableMapOf(1L to 50L), + ) + + val result = downloadProgressTracker.calculateProgress(null, progress) + + assertEquals(-1, result) + } } 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 e6f81221d..712b1ba50 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 @@ -409,6 +409,25 @@ class InstallOrchestratorTest { verifyMockito(appInstallRepository, times(1)).getDownloads() } + @Test + fun init_handlesReevaluationErrorGracefully() = runTest { + val awaiting = createAppInstall(id = "app.awaiting.error", status = Status.AWAITING) + mockInstallWorkManagerSuccess() + + whenever(appInstallRepository.getDownloads()) + .thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting))) + whenever(appInstallRepository.getDownloadList()) + .thenThrow(RuntimeException("repository failed")) + .thenReturn(listOf(awaiting)) + + val installOrchestrator = InstallOrchestrator(context, createOrchestratorScope(), appManager, appInstallRepository) + + installOrchestrator.init() + advanceOrchestrator() + + verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } + @Test fun init_isIdempotent() = runTest { val awaiting = createAppInstall(id = "app.awaiting.once", status = Status.AWAITING) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt index aaffc3f0d..24bdb9d5f 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt @@ -163,6 +163,45 @@ class InstallationCompletionHandlerTest { verify { UpdatesNotifier.showNotification(context, "Update", "Updated message") } } + @Test + fun onInstallFinished_doesNothingWhenAppInstallIsNull() = runTest { + handler.onInstallFinished(null, true) + + verify(exactly = 0) { appInstallationManager.getInstallationStatus(any()) } + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + + @Test + fun onInstallFinished_doesNotTrackNonInstalledStatus() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLATION_ISSUE + + handler.onInstallFinished(appInstall, true) + + assertTrue(UpdatesDao.successfulUpdatedApps.isEmpty()) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + + @Test + fun onInstallFinished_doesNotNotifyWhenNoSuccessfulUpdates() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) + appInstallRepository.addDownload( + AppInstall( + id = "pending", + status = Status.AWAITING, + packageName = "com.example.pending", + isUpdateRequest = true + ) + ) + every { appInstallationManager.getInstallationStatus(appInstall) } returns Status.INSTALLED + + handler.onInstallFinished(appInstall, true) + + assertTrue(UpdatesDao.successfulUpdatedApps.contains(appInstall)) + verify(exactly = 0) { UpdatesNotifier.showNotification(any(), any(), any()) } + } + @Test fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest { val appInstall = AppInstall(id = "123", packageName = "com.example.app", isUpdateRequest = true) -- GitLab From 5ac17dd59f34618cc05a71237fc26092f3fc1e62 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 1 May 2026 18:48:03 +0600 Subject: [PATCH 6/7] test(InstallStatusStream): fix mock to emit multiple values Use returnsMany instead of returns for getInstalledPwaUrls() and add runCurrent() call to properly handle flow emissions in the test. --- .../e/apps/ui/compose/state/InstallStatusStreamTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt index 661fbf2f7..d6ac2a627 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt @@ -81,7 +81,10 @@ class InstallStatusStreamTest { listOf(appInfo("com.example.one")), listOf(appInfo("com.example.two")), ) - every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") + every { pwaManager.getInstalledPwaUrls() } returnsMany listOf( + setOf("https://pwa.example"), + setOf("https://pwa.example"), + ) val stream = InstallStatusStream(appManager, appLoungePackageManager, pwaManager) val collectedSnapshots = backgroundScope.async { @@ -90,6 +93,8 @@ class InstallStatusStreamTest { .toList() } + runCurrent() + advanceTimeBy(50) runCurrent() advanceTimeBy(50) runCurrent() -- GitLab From 4c7de56ffe5d9574d57ba07a273dfc5f7bb97fa4 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Wed, 6 May 2026 13:11:16 +0600 Subject: [PATCH 7/7] fix(updates): ensure off-screen updates have correct button state/UI Keep the updates list and adapter state in sync when progress tracking loses a download so recycled rows stop showing stale install controls. --- .../e/apps/ui/updates/UpdatesFragment.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 8951d1726..101c75b3b 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 @@ -440,7 +440,7 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) if (progress == -1) { - restoreInstallButton(adapter, recyclerView, index, fusedApp) + restoreInstallButton(fusedApp) return@forEachIndexed } @@ -453,24 +453,24 @@ class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationI } } - private fun restoreInstallButton( - adapter: ApplicationListRVAdapter, - recyclerView: RecyclerView, - index: Int, - fusedApp: Application - ) { + private fun restoreInstallButton(fusedApp: Application) { val restoredApp = fusedApp.copy() mainActivityViewModel.updateStatusOfFusedApps( applicationList = listOf(restoredApp), appInstallList = emptyList() ) - displayedUpdates.find { it._id == restoredApp._id }?.status = restoredApp.status - updateButtonAvailability() - val viewHolder = recyclerView.findViewHolderForAdapterPosition(index) - as? ApplicationListRVAdapter.ViewHolder - ?: return - adapter.bindInstallControls(viewHolder, restoredApp) + val updatedApps = displayedUpdates.map { displayedApp -> + if (displayedApp._id == restoredApp._id) { + displayedApp.copy(status = restoredApp.status) + } else { + displayedApp + } + } + + displayedUpdates = updatedApps + updatesListAdapter?.setData(updatedApps) + updateButtonAvailability() } override fun onDestroyView() { -- GitLab