From 42c5b79461787e7799dbb298cc902c83ae61adae Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 21:37:40 +0600 Subject: [PATCH 01/10] refactor: isolate AppInstallProcessor platform dependencies Created thin wrappers for global EventBus, storage, parental control auth checking, updates DAO, updates notifier, and network connectivity check. These wrappers replace direct static calls without changing existing behaviour and introduce mockable seams for tests in AppInstallProcessorTest. --- .../AppInstallProcessorBindingsModule.kt | 66 +++++++ .../workmanager/AppInstallProcessor.kt | 53 +++--- .../install/wrapper/AppEventDispatcher.kt | 33 ++++ .../install/wrapper/NetworkStatusChecker.kt | 36 ++++ .../wrapper/ParentalControlAuthGateway.kt | 32 ++++ .../install/wrapper/StorageSpaceChecker.kt | 33 ++++ .../wrapper/UpdatesNotificationSender.kt | 36 ++++ .../data/install/wrapper/UpdatesTracker.kt | 51 +++++ .../AppInstallProcessorTest.kt | 174 ++++++++---------- 9 files changed, 394 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/AppEventDispatcher.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/NetworkStatusChecker.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGateway.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceChecker.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSender.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTracker.kt diff --git a/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt new file mode 100644 index 000000000..ed205c78b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt @@ -0,0 +1,66 @@ +/* + * 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.di.bindings + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.DefaultAppEventDispatcher +import foundation.e.apps.data.install.wrapper.DeviceNetworkStatusChecker +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGatewayImpl +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.data.install.wrapper.StorageSpaceCheckerImpl +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSenderImpl +import foundation.e.apps.data.install.wrapper.UpdatesTracker +import foundation.e.apps.data.install.wrapper.UpdatesTrackerImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface AppInstallProcessorBindingsModule { + + @Binds + @Singleton + fun bindAppEventDispatcher(dispatcher: DefaultAppEventDispatcher): AppEventDispatcher + + @Binds + @Singleton + fun bindStorageSpaceChecker(checker: StorageSpaceCheckerImpl): StorageSpaceChecker + + @Binds + @Singleton + fun bindParentalControlAuthGateway(gateway: ParentalControlAuthGatewayImpl): ParentalControlAuthGateway + + @Binds + @Singleton + fun bindUpdatesTracker(tracker: UpdatesTrackerImpl): UpdatesTracker + + @Binds + @Singleton + fun bindUpdatesNotificationSender(sender: UpdatesNotificationSenderImpl): UpdatesNotificationSender + + @Binds + @Singleton + fun bindNetworkStatusChecker(checker: DeviceNetworkStatusChecker): NetworkStatusChecker +} 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 3b214d475..9472af0e1 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 @@ -25,25 +25,25 @@ import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.application.UpdatesDao import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.Type import foundation.e.apps.data.event.AppEvent -import foundation.e.apps.data.event.EventBus import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.download.DownloadManagerUtils import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.updates.UpdatesNotifier +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender +import foundation.e.apps.data.install.wrapper.UpdatesTracker import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.data.system.ParentalControlAuthenticator -import foundation.e.apps.data.system.StorageComputer -import foundation.e.apps.data.system.isNetworkAvailable import foundation.e.apps.data.utils.getFormattedString import foundation.e.apps.domain.ValidateAppAgeLimitUseCase import foundation.e.apps.domain.model.ContentRatingValidity @@ -66,6 +66,12 @@ class AppInstallProcessor @Inject constructor( private val sessionRepository: SessionRepository, private val playStoreAuthStore: PlayStoreAuthStore, private val storageNotificationManager: StorageNotificationManager, + private val appEventDispatcher: AppEventDispatcher, + private val storageSpaceChecker: StorageSpaceChecker, + private val parentalControlAuthGateway: ParentalControlAuthGateway, + private val updatesTracker: UpdatesTracker, + private val updatesNotificationSender: UpdatesNotificationSender, + private val networkStatusChecker: NetworkStatusChecker, ) { @Inject lateinit var downloadManager: DownloadManagerUtils @@ -137,7 +143,9 @@ class AppInstallProcessor @Inject constructor( if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { val authData = playStoreAuthStore.awaitAuthData() if (!appInstall.isFree && authData?.isAnonymous == true) { - EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message)) + appEventDispatcher.dispatch( + AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message) + ) } } @@ -171,17 +179,17 @@ class AppInstallProcessor @Inject constructor( return false } - if (!context.isNetworkAvailable()) { + if (!networkStatusChecker.isNetworkAvailable()) { appInstallComponents.appManagerWrapper.installationIssue(appInstall) - EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) + appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false)) return false } - if (StorageComputer.spaceMissing(appInstall) > 0) { + if (storageSpaceChecker.spaceMissing(appInstall) > 0) { Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") storageNotificationManager.showNotEnoughSpaceNotification(appInstall) appInstallComponents.appManagerWrapper.installationIssue(appInstall) - EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) + appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) return false } @@ -196,13 +204,13 @@ class AppInstallProcessor @Inject constructor( if (ageLimitValidationResult.isSuccess()) { awaitInvokeAgeLimitEvent(appInstall.name) if (ageLimitValidationResult.data?.requestPin == true) { - val isAuthenticated = ParentalControlAuthenticator.awaitAuthentication() + val isAuthenticated = parentalControlAuthGateway.awaitAuthentication() if (isAuthenticated) { ageLimitValidationResult.setData(ContentRatingValidity(true)) } } } else { - EventBus.invokeEvent(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) + appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) } var ageIsValid = true @@ -215,7 +223,7 @@ class AppInstallProcessor @Inject constructor( suspend fun awaitInvokeAgeLimitEvent(type: String) { val deferred = CompletableDeferred() - EventBus.invokeEvent(AppEvent.AgeLimitRestrictionEvent(type, deferred)) + appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred)) deferred.await() // await closing dialog box } @@ -229,7 +237,7 @@ class AppInstallProcessor @Inject constructor( return false } appInstallComponents.appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) - EventBus.invokeEvent(AppEvent.AppPurchaseEvent(appInstall)) + appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) return false } catch (e: GplayHttpRequestException) { handleUpdateDownloadError( @@ -252,7 +260,7 @@ class AppInstallProcessor @Inject constructor( } private suspend fun handleAppRestricted(appInstall: AppInstall) { - EventBus.invokeEvent(AppEvent.AppRestrictedOrUnavailable(appInstall)) + appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) appManager.addDownload(appInstall) appManager.updateUnavailable(appInstall) } @@ -263,7 +271,7 @@ class AppInstallProcessor @Inject constructor( e: Exception ) { Timber.e(e, "Updating download Urls failed for $message") - EventBus.invokeEvent( + appEventDispatcher.dispatch( AppEvent.UpdateEvent( ResultSupreme.WorkError( ResultStatus.UNKNOWN, @@ -363,12 +371,12 @@ class AppInstallProcessor @Inject constructor( appInstallComponents.appManagerWrapper.getFusedDownloadPackageStatus(appInstall) if (packageStatus == Status.INSTALLED) { - UpdatesDao.addSuccessfullyUpdatedApp(it) + updatesTracker.addSuccessfullyUpdatedApp(it) } if (isUpdateCompleted()) { // show notification for ended update showNotificationOnUpdateEnded() - UpdatesDao.clearSuccessfullyUpdatedApps() + updatesTracker.clearSuccessfullyUpdatedApps() } } } @@ -383,18 +391,17 @@ class AppInstallProcessor @Inject constructor( ).contains(it.status) } - return UpdatesDao.successfulUpdatedApps.isNotEmpty() && downloadListWithoutAnyIssue.isEmpty() + return updatesTracker.hasSuccessfulUpdatedApps() && downloadListWithoutAnyIssue.isEmpty() } private suspend fun showNotificationOnUpdateEnded() { val locale = playStoreAuthStore.awaitAuthData()?.locale ?: java.util.Locale.getDefault() val date = Date().getFormattedString(DATE_FORMAT, locale) val numberOfUpdatedApps = - NumberFormat.getNumberInstance(locale).format(UpdatesDao.successfulUpdatedApps.size) + NumberFormat.getNumberInstance(locale).format(updatesTracker.successfulUpdatedAppsCount()) .toString() - UpdatesNotifier.showNotification( - context, + updatesNotificationSender.showNotification( context.getString(R.string.update), context.getString( R.string.message_last_update_triggered, diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/AppEventDispatcher.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/AppEventDispatcher.kt new file mode 100644 index 000000000..e9c44ea58 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/AppEventDispatcher.kt @@ -0,0 +1,33 @@ +/* + * 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.wrapper + +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.event.EventBus +import javax.inject.Inject + +interface AppEventDispatcher { + suspend fun dispatch(event: AppEvent) +} + +class DefaultAppEventDispatcher @Inject constructor() : AppEventDispatcher { + override suspend fun dispatch(event: AppEvent) { + EventBus.invokeEvent(event) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/NetworkStatusChecker.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/NetworkStatusChecker.kt new file mode 100644 index 000000000..612ed2b0c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/NetworkStatusChecker.kt @@ -0,0 +1,36 @@ +/* + * 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.wrapper + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.system.isNetworkAvailable +import javax.inject.Inject + +interface NetworkStatusChecker { + fun isNetworkAvailable(): Boolean +} + +class DeviceNetworkStatusChecker @Inject constructor( + @ApplicationContext private val context: Context +) : NetworkStatusChecker { + override fun isNetworkAvailable(): Boolean { + return context.isNetworkAvailable() + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGateway.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGateway.kt new file mode 100644 index 000000000..36829868b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGateway.kt @@ -0,0 +1,32 @@ +/* + * 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.wrapper + +import foundation.e.apps.data.system.ParentalControlAuthenticator +import javax.inject.Inject + +interface ParentalControlAuthGateway { + suspend fun awaitAuthentication(): Boolean +} + +class ParentalControlAuthGatewayImpl @Inject constructor() : ParentalControlAuthGateway { + override suspend fun awaitAuthentication(): Boolean { + return ParentalControlAuthenticator.awaitAuthentication() + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceChecker.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceChecker.kt new file mode 100644 index 000000000..e4b330af9 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceChecker.kt @@ -0,0 +1,33 @@ +/* + * 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.wrapper + +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.system.StorageComputer +import javax.inject.Inject + +interface StorageSpaceChecker { + fun spaceMissing(appInstall: AppInstall): Long +} + +class StorageSpaceCheckerImpl @Inject constructor() : StorageSpaceChecker { + override fun spaceMissing(appInstall: AppInstall): Long { + return StorageComputer.spaceMissing(appInstall) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSender.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSender.kt new file mode 100644 index 000000000..97a226c93 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSender.kt @@ -0,0 +1,36 @@ +/* + * 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.wrapper + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.data.install.updates.UpdatesNotifier +import javax.inject.Inject + +interface UpdatesNotificationSender { + fun showNotification(title: String, message: String) +} + +class UpdatesNotificationSenderImpl @Inject constructor( + @ApplicationContext private val context: Context +) : UpdatesNotificationSender { + override fun showNotification(title: String, message: String) { + UpdatesNotifier.showNotification(context, title, message) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTracker.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTracker.kt new file mode 100644 index 000000000..26bf2e1ba --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTracker.kt @@ -0,0 +1,51 @@ +/* + * 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.wrapper + +import foundation.e.apps.data.application.UpdatesDao +import foundation.e.apps.data.install.models.AppInstall +import javax.inject.Inject + +interface UpdatesTracker { + fun addSuccessfullyUpdatedApp(appInstall: AppInstall) + + fun clearSuccessfullyUpdatedApps() + + fun hasSuccessfulUpdatedApps(): Boolean + + fun successfulUpdatedAppsCount(): Int +} + +class UpdatesTrackerImpl @Inject constructor() : UpdatesTracker { + override fun addSuccessfullyUpdatedApp(appInstall: AppInstall) { + UpdatesDao.addSuccessfullyUpdatedApp(appInstall) + } + + override fun clearSuccessfullyUpdatedApps() { + UpdatesDao.clearSuccessfullyUpdatedApps() + } + + override fun hasSuccessfulUpdatedApps(): Boolean { + return UpdatesDao.successfulUpdatedApps.isNotEmpty() + } + + override fun successfulUpdatedAppsCount(): Int { + return UpdatesDao.successfulUpdatedApps.size + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index a492a7377..baddf31b0 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -19,9 +19,6 @@ package foundation.e.apps.installProcessor import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities import androidx.arch.core.executor.testing.InstantTaskExecutorRule import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationRepository @@ -36,16 +33,20 @@ import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.data.system.StorageComputer +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender +import foundation.e.apps.data.install.wrapper.UpdatesTracker import foundation.e.apps.domain.ValidateAppAgeLimitUseCase import foundation.e.apps.domain.model.ContentRatingValidity import foundation.e.apps.domain.preferences.SessionRepository import foundation.e.apps.util.MainCoroutineRule import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -56,8 +57,6 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class AppInstallProcessorTest { @@ -101,9 +100,22 @@ class AppInstallProcessorTest { @Mock private lateinit var storageNotificationManager: StorageNotificationManager + private lateinit var appEventDispatcher: AppEventDispatcher + private lateinit var storageSpaceChecker: StorageSpaceChecker + private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway + private lateinit var updatesTracker: UpdatesTracker + private lateinit var updatesNotificationSender: UpdatesNotificationSender + private lateinit var networkStatusChecker: NetworkStatusChecker + @Before fun setup() { MockitoAnnotations.openMocks(this) + appEventDispatcher = mockk(relaxed = true) + storageSpaceChecker = mockk(relaxed = true) + parentalControlAuthGateway = mockk(relaxed = true) + updatesTracker = mockk(relaxed = true) + updatesNotificationSender = mockk(relaxed = true) + networkStatusChecker = mockk(relaxed = true) fakeFusedDownloadDAO = FakeAppInstallDAO() appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO) fakeFusedManagerRepository = @@ -118,7 +130,13 @@ class AppInstallProcessorTest { validateAppAgeRatingUseCase, sessionRepository, playStoreAuthStore, - storageNotificationManager + storageNotificationManager, + appEventDispatcher, + storageSpaceChecker, + parentalControlAuthGateway, + updatesTracker, + updatesNotificationSender, + networkStatusChecker ) } @@ -237,21 +255,16 @@ class AppInstallProcessorTest { val appManagerWrapper = mockk(relaxed = true) val processor = createProcessorForCanEnqueue(appManagerWrapper) - mockkObject(StorageComputer) - try { - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0 - val result = processor.canEnqueue(appInstall) + val result = processor.canEnqueue(appInstall) - assertTrue(result) - coVerify { appManagerWrapper.addDownload(appInstall) } - } finally { - unmockkObject(StorageComputer) - } + assertTrue(result) + coVerify { appManagerWrapper.addDownload(appInstall) } } @Test @@ -267,21 +280,16 @@ class AppInstallProcessorTest { val appManagerWrapper = mockk(relaxed = true) val processor = createProcessorForCanEnqueue(appManagerWrapper) - mockkObject(StorageComputer) - try { - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - everyNetworkUnavailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0 + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns false + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0 - val result = processor.canEnqueue(appInstall) + val result = processor.canEnqueue(appInstall) - assertEquals(false, result) - coVerify { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } + assertEquals(false, result) + coVerify { appManagerWrapper.installationIssue(appInstall) } } @Test @@ -297,22 +305,17 @@ class AppInstallProcessorTest { val appManagerWrapper = mockk(relaxed = true) val processor = createProcessorForCanEnqueue(appManagerWrapper) - mockkObject(StorageComputer) - try { - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 100L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall) - coVerify { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall) + coVerify { appManagerWrapper.installationIssue(appInstall) } } @Test @@ -328,21 +331,16 @@ class AppInstallProcessorTest { val appManagerWrapper = mockk(relaxed = true) val processor = createProcessorForCanEnqueue(appManagerWrapper) - mockkObject(StorageComputer) - try { - coEvery { appManagerWrapper.addDownload(appInstall) } returns false - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + coEvery { appManagerWrapper.addDownload(appInstall) } returns false + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L - val result = processor.canEnqueue(appInstall) + val result = processor.canEnqueue(appInstall) - assertEquals(false, result) - coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } + assertEquals(false, result) + coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } } @Test @@ -358,21 +356,16 @@ class AppInstallProcessorTest { val appManagerWrapper = mockk(relaxed = true) val processor = createProcessorForCanEnqueue(appManagerWrapper) - mockkObject(StorageComputer) - try { - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) - everyNetworkAvailable() - io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L - val result = processor.canEnqueue(appInstall) + val result = processor.canEnqueue(appInstall) - assertEquals(false, result) - coVerify { appManagerWrapper.cancelDownload(appInstall) } - } finally { - unmockkObject(StorageComputer) - } + assertEquals(false, result) + coVerify { appManagerWrapper.cancelDownload(appInstall) } } private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { @@ -404,26 +397,13 @@ class AppInstallProcessorTest { validateAppAgeRatingUseCase, sessionRepository, playStoreAuthStore, - storageNotificationManager + storageNotificationManager, + appEventDispatcher, + storageSpaceChecker, + parentalControlAuthGateway, + updatesTracker, + updatesNotificationSender, + networkStatusChecker ) } - - private fun everyNetworkAvailable() { - val connectivityManager = mock() - val network = mock() - val networkCapabilities = mock() - whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager) - whenever(connectivityManager.activeNetwork).thenReturn(network) - whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities) - whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)).thenReturn(true) - whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).thenReturn(true) - } - - private fun everyNetworkUnavailable() { - val connectivityManager = mock() - val network = mock() - whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager) - whenever(connectivityManager.activeNetwork).thenReturn(network) - whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(null) - } } -- GitLab From a4844bf432e3dff7f7ee6071ace6746207ab8f22 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 21:48:33 +0600 Subject: [PATCH 02/10] test: capture AppInstallProcessor baseline tests for existing behaviour Lock down the current enqueue, worker, and update-completion quirks before extraction so later class splits can be checked against explicit behavior instead of assumptions. --- .../AppInstallProcessorTest.kt | 252 +++++++++++++++++- 1 file changed, 244 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index baddf31b0..97352f8e2 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -20,10 +20,14 @@ package foundation.e.apps.installProcessor import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.aurora.gplayapi.data.models.AuthData import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.R import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.AppInstallRepository @@ -31,22 +35,29 @@ import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.workmanager.AppInstallProcessor -import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.data.install.wrapper.AppEventDispatcher import foundation.e.apps.data.install.wrapper.NetworkStatusChecker import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway import foundation.e.apps.data.install.wrapper.StorageSpaceChecker import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender import foundation.e.apps.data.install.wrapper.UpdatesTracker +import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.domain.ValidateAppAgeLimitUseCase import foundation.e.apps.domain.model.ContentRatingValidity +import foundation.e.apps.domain.model.User import foundation.e.apps.domain.preferences.SessionRepository import foundation.e.apps.util.MainCoroutineRule 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.unmockkObject +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -57,6 +68,7 @@ import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) class AppInstallProcessorTest { @@ -80,16 +92,12 @@ class AppInstallProcessorTest { @Mock private lateinit var fakeFDroidRepository: FDroidRepository - @Mock private lateinit var context: Context - @Mock private lateinit var sessionRepository: SessionRepository - @Mock private lateinit var playStoreAuthStore: PlayStoreAuthStore - @Mock private lateinit var applicationRepository: ApplicationRepository private lateinit var appInstallProcessor: AppInstallProcessor @@ -100,7 +108,7 @@ class AppInstallProcessorTest { @Mock private lateinit var storageNotificationManager: StorageNotificationManager - private lateinit var appEventDispatcher: AppEventDispatcher + private lateinit var appEventDispatcher: RecordingAppEventDispatcher private lateinit var storageSpaceChecker: StorageSpaceChecker private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway private lateinit var updatesTracker: UpdatesTracker @@ -110,7 +118,13 @@ class AppInstallProcessorTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - appEventDispatcher = mockk(relaxed = true) + context = mockk(relaxed = true) + sessionRepository = mockk(relaxed = true) + playStoreAuthStore = mockk(relaxed = true) + applicationRepository = mockk(relaxed = true) + coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE + coEvery { playStoreAuthStore.awaitAuthData() } returns null + appEventDispatcher = RecordingAppEventDispatcher() storageSpaceChecker = mockk(relaxed = true) parentalControlAuthGateway = mockk(relaxed = true) updatesTracker = mockk(relaxed = true) @@ -242,6 +256,185 @@ class AppInstallProcessorTest { assertEquals("processInstall", finalFusedDownload, null) } + @Test + fun `enqueueFusedDownload warns anonymous paid users without aborting`() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.paid", + isFree = false + ) + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + mockkObject(InstallWorkManager) + try { + coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS + coEvery { + playStoreAuthStore.awaitAuthData() + } returns AuthData(email = "anon@example.com", isAnonymous = true) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } + + val result = processor.enqueueFusedDownload(appInstall) + + assertTrue(result) + assertTrue(appEventDispatcher.events.any { + it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message + }) + coVerify { appManagerWrapper.updateAwaiting(appInstall) } + verify { InstallWorkManager.enqueueWork(context, appInstall, false) } + } finally { + unmockkObject(InstallWorkManager) + } + } + + @Test + fun `canEnqueue refreshes download urls for non-PWA installs`() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.NATIVE, + source = Source.PLAY_STORE, + id = "123", + status = Status.AWAITING, + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = processor.canEnqueue(appInstall) + + assertTrue(result) + coVerify { applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) } + coVerify { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun `canEnqueue returns false when download url refresh throws http error`() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.NATIVE, + source = Source.PLAY_STORE, + id = "123", + status = Status.AWAITING, + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws GplayHttpRequestException(403, "forbidden") + + val result = processor.canEnqueue(appInstall) + + assertEquals(false, result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent }) + coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun `canEnqueue keeps going when download url refresh throws illegal state`() = runTest { + val appInstall = AppInstall( + type = foundation.e.apps.data.enums.Type.NATIVE, + source = Source.PLAY_STORE, + id = "123", + status = Status.AWAITING, + packageName = "com.example.app" + ) + + val appManagerWrapper = mockk(relaxed = true) + val processor = createProcessorForCanEnqueue(appManagerWrapper) + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws IllegalStateException("boom") + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = processor.canEnqueue(appInstall) + + assertTrue(result) + coVerify { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun `processInstall returns success when internal exception occurs`() = runTest { + val fusedDownload = initTest() + fakeFusedManagerRepository.forceCrash = true + + val result = appInstallProcessor.processInstall(fusedDownload.id, false) { + // _ignored_ + } + + assertTrue(result.isSuccess) + assertEquals(ResultStatus.OK, result.getOrNull()) + } + + @Test + fun `processInstall enters foreground before download starts`() = runTest { + val fusedDownload = initTest() + var statusAtForeground: Status? = null + + appInstallProcessor.processInstall(fusedDownload.id, false) { + statusAtForeground = fusedDownload.status + } + + assertEquals(Status.AWAITING, statusAtForeground) + } + + @Test + fun `processInstall update completion ignores installation issues`() = runTest { + stubUpdateNotificationContext() + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 1 + + processCompletedUpdate(Status.INSTALLATION_ISSUE) + + verify { updatesTracker.addSuccessfullyUpdatedApp(any()) } + verify { updatesNotificationSender.showNotification("Update", "Updated message") } + verify { updatesTracker.clearSuccessfullyUpdatedApps() } + } + + @Test + fun `processInstall update completion ignores purchase needed`() = runTest { + stubUpdateNotificationContext() + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 1 + + processCompletedUpdate(Status.PURCHASE_NEEDED) + + verify { updatesTracker.addSuccessfullyUpdatedApp(any()) } + verify { updatesNotificationSender.showNotification("Update", "Updated message") } + verify { updatesTracker.clearSuccessfullyUpdatedApps() } + } + + @Test + fun `processInstall clears tracked updates after final notification`() = runTest { + stubUpdateNotificationContext() + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 1 + + processCompletedUpdate() + + verify { updatesTracker.clearSuccessfullyUpdatedApps() } + } + @Test fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { val appInstall = AppInstall( @@ -375,6 +568,41 @@ class AppInstallProcessorTest { return fakeFusedDownloadDAO.getDownloadById(appInstall.id) } + private suspend fun processCompletedUpdate(ignoredStatus: Status? = null) { + val fusedDownload = initTest() + fakeFusedManagerRepository.isAppInstalled = true + + ignoredStatus?.let { + fakeFusedDownloadDAO.addDownload( + AppInstall( + id = "ignored-$it", + status = it, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.$it" + ) + ) + } + + appInstallProcessor.processInstall(fusedDownload.id, true) { + // _ignored_ + } + } + + private suspend fun stubUpdateNotificationContext() { + val authData = AuthData(email = "user@example.com", isAnonymous = false).apply { + locale = Locale.US + } + coEvery { playStoreAuthStore.awaitAuthData() } returns authData + every { context.getString(R.string.update) } returns "Update" + every { + context.getString( + R.string.message_last_update_triggered, + any(), + any() + ) + } returns "Updated message" + } + private fun createFusedDownload( packageName: String? = null, downloadUrlList: MutableList? = null @@ -407,3 +635,11 @@ class AppInstallProcessorTest { ) } } + +private class RecordingAppEventDispatcher : AppEventDispatcher { + val events = mutableListOf() + + override suspend fun dispatch(event: AppEvent) { + events.add(event) + } +} -- GitLab From 2c1e4d90836d39df9dca0964a1501668b4ac7fd6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 22:27:54 +0600 Subject: [PATCH 03/10] refactor: extract update completion handling from AppInstallProcessor Move update bookkeeping and final notification logic behind AppUpdateCompletionHandler so the worker flow can keep its current behavior while the update rules gain their own test coverage. --- .../workmanager/AppInstallProcessor.kt | 54 +----- .../workmanager/AppUpdateCompletionHandler.kt | 93 ++++++++++ .../AppInstallProcessorTest.kt | 22 ++- .../AppUpdateCompletionHandlerTest.kt | 166 ++++++++++++++++++ 4 files changed, 276 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt 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 9472af0e1..aafc42758 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 @@ -40,11 +40,8 @@ import foundation.e.apps.data.install.wrapper.AppEventDispatcher import foundation.e.apps.data.install.wrapper.NetworkStatusChecker import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway import foundation.e.apps.data.install.wrapper.StorageSpaceChecker -import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender -import foundation.e.apps.data.install.wrapper.UpdatesTracker import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.data.utils.getFormattedString import foundation.e.apps.domain.ValidateAppAgeLimitUseCase import foundation.e.apps.domain.model.ContentRatingValidity import foundation.e.apps.domain.model.User @@ -53,8 +50,6 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.transformWhile import timber.log.Timber -import java.text.NumberFormat -import java.util.Date import javax.inject.Inject @Suppress("LongParameterList") @@ -69,9 +64,8 @@ class AppInstallProcessor @Inject constructor( private val appEventDispatcher: AppEventDispatcher, private val storageSpaceChecker: StorageSpaceChecker, private val parentalControlAuthGateway: ParentalControlAuthGateway, - private val updatesTracker: UpdatesTracker, - private val updatesNotificationSender: UpdatesNotificationSender, private val networkStatusChecker: NetworkStatusChecker, + private val appUpdateCompletionHandler: AppUpdateCompletionHandler, ) { @Inject lateinit var downloadManager: DownloadManagerUtils @@ -83,7 +77,6 @@ class AppInstallProcessor @Inject constructor( companion object { private const val TAG = "AppInstallProcessor" - private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm" } /** @@ -365,50 +358,7 @@ class AppInstallProcessor @Inject constructor( private suspend fun checkUpdateWork( appInstall: AppInstall? ) { - if (isItUpdateWork) { - appInstall?.let { - val packageStatus = - appInstallComponents.appManagerWrapper.getFusedDownloadPackageStatus(appInstall) - - if (packageStatus == Status.INSTALLED) { - updatesTracker.addSuccessfullyUpdatedApp(it) - } - - if (isUpdateCompleted()) { // show notification for ended update - showNotificationOnUpdateEnded() - updatesTracker.clearSuccessfullyUpdatedApps() - } - } - } - } - - private suspend fun isUpdateCompleted(): Boolean { - val downloadListWithoutAnyIssue = - appInstallComponents.appInstallRepository.getDownloadList().filter { - !listOf( - Status.INSTALLATION_ISSUE, - Status.PURCHASE_NEEDED - ).contains(it.status) - } - - return updatesTracker.hasSuccessfulUpdatedApps() && downloadListWithoutAnyIssue.isEmpty() - } - - private suspend fun showNotificationOnUpdateEnded() { - val locale = playStoreAuthStore.awaitAuthData()?.locale ?: java.util.Locale.getDefault() - val date = Date().getFormattedString(DATE_FORMAT, locale) - val numberOfUpdatedApps = - NumberFormat.getNumberInstance(locale).format(updatesTracker.successfulUpdatedAppsCount()) - .toString() - - updatesNotificationSender.showNotification( - context.getString(R.string.update), - context.getString( - R.string.message_last_update_triggered, - numberOfUpdatedApps, - date - ) - ) + appUpdateCompletionHandler.onInstallFinished(appInstall, isItUpdateWork) } private suspend fun startAppInstallationProcess(appInstall: AppInstall) { diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt new file mode 100644 index 000000000..a3dce75ca --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt @@ -0,0 +1,93 @@ +/* + * 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.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender +import foundation.e.apps.data.install.wrapper.UpdatesTracker +import foundation.e.apps.data.preference.PlayStoreAuthStore +import foundation.e.apps.data.utils.getFormattedString +import java.text.NumberFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +class AppUpdateCompletionHandler @Inject constructor( + @ApplicationContext private val context: Context, + private val appInstallRepository: AppInstallRepository, + private val appManagerWrapper: AppManagerWrapper, + private val playStoreAuthStore: PlayStoreAuthStore, + private val updatesTracker: UpdatesTracker, + private val updatesNotificationSender: UpdatesNotificationSender, +) { + companion object { + private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm" + } + + suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) { + if (!isUpdateWork) { + return + } + + appInstall?.let { + val packageStatus = appManagerWrapper.getFusedDownloadPackageStatus(appInstall) + + if (packageStatus == Status.INSTALLED) { + updatesTracker.addSuccessfullyUpdatedApp(it) + } + + if (isUpdateCompleted()) { + showNotificationOnUpdateEnded() + updatesTracker.clearSuccessfullyUpdatedApps() + } + } + } + + private suspend fun isUpdateCompleted(): Boolean { + val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter { + !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status) + } + + return updatesTracker.hasSuccessfulUpdatedApps() && downloadListWithoutAnyIssue.isEmpty() + } + + private suspend fun showNotificationOnUpdateEnded() { + val locale = playStoreAuthStore.awaitAuthData()?.locale ?: Locale.getDefault() + val date = Date().getFormattedString(DATE_FORMAT, locale) + val numberOfUpdatedApps = + NumberFormat.getNumberInstance(locale) + .format(updatesTracker.successfulUpdatedAppsCount()) + .toString() + + updatesNotificationSender.showNotification( + context.getString(R.string.update), + context.getString( + R.string.message_last_update_triggered, + numberOfUpdatedApps, + date + ) + ) + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 97352f8e2..9d3460510 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -36,13 +36,14 @@ import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler +import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.NetworkStatusChecker import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway import foundation.e.apps.data.install.wrapper.StorageSpaceChecker import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender import foundation.e.apps.data.install.wrapper.UpdatesTracker import foundation.e.apps.data.install.workmanager.AppInstallProcessor -import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.domain.ValidateAppAgeLimitUseCase @@ -114,6 +115,7 @@ class AppInstallProcessorTest { private lateinit var updatesTracker: UpdatesTracker private lateinit var updatesNotificationSender: UpdatesNotificationSender private lateinit var networkStatusChecker: NetworkStatusChecker + private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler @Before fun setup() { @@ -136,6 +138,14 @@ class AppInstallProcessorTest { FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository) val appInstallComponents = AppInstallComponents(appInstallRepository, fakeFusedManagerRepository) + appUpdateCompletionHandler = AppUpdateCompletionHandler( + context, + appInstallRepository, + fakeFusedManagerRepository, + playStoreAuthStore, + updatesTracker, + updatesNotificationSender + ) appInstallProcessor = AppInstallProcessor( context, @@ -148,9 +158,8 @@ class AppInstallProcessorTest { appEventDispatcher, storageSpaceChecker, parentalControlAuthGateway, - updatesTracker, - updatesNotificationSender, - networkStatusChecker + networkStatusChecker, + appUpdateCompletionHandler ) } @@ -629,9 +638,8 @@ class AppInstallProcessorTest { appEventDispatcher, storageSpaceChecker, parentalControlAuthGateway, - updatesTracker, - updatesNotificationSender, - networkStatusChecker + networkStatusChecker, + appUpdateCompletionHandler ) } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt new file mode 100644 index 000000000..9bb3a2d46 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt @@ -0,0 +1,166 @@ +/* + * 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.installProcessor + +import android.content.Context +import com.aurora.gplayapi.data.models.AuthData +import foundation.e.apps.R +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler +import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender +import foundation.e.apps.data.install.wrapper.UpdatesTracker +import foundation.e.apps.data.preference.PlayStoreAuthStore +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.Locale + +@OptIn(ExperimentalCoroutinesApi::class) +class AppUpdateCompletionHandlerTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var appInstallRepository: AppInstallRepository + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var playStoreAuthStore: PlayStoreAuthStore + private lateinit var updatesTracker: UpdatesTracker + private lateinit var updatesNotificationSender: UpdatesNotificationSender + private lateinit var context: Context + private lateinit var handler: AppUpdateCompletionHandler + + @Before + fun setup() { + context = mockk(relaxed = true) + appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) + appManagerWrapper = mockk(relaxed = true) + playStoreAuthStore = mockk(relaxed = true) + updatesTracker = mockk(relaxed = true) + updatesNotificationSender = mockk(relaxed = true) + coEvery { playStoreAuthStore.awaitAuthData() } returns null + handler = AppUpdateCompletionHandler( + context, + appInstallRepository, + appManagerWrapper, + playStoreAuthStore, + updatesTracker, + updatesNotificationSender + ) + } + + @Test + fun onInstallFinished_doesNothingWhenNotUpdateWork() = runTest { + handler.onInstallFinished(AppInstall(id = "123", packageName = "com.example.app"), false) + + verify(exactly = 0) { appManagerWrapper.getFusedDownloadPackageStatus(any()) } + verify(exactly = 0) { updatesTracker.addSuccessfullyUpdatedApp(any()) } + verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) } + } + + @Test + fun onInstallFinished_tracksInstalledUpdates() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED + every { updatesTracker.hasSuccessfulUpdatedApps() } returns false + + handler.onInstallFinished(appInstall, true) + + verify { updatesTracker.addSuccessfullyUpdatedApp(appInstall) } + verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) } + } + + @Test + fun onInstallFinished_sendsNotificationWhenUpdateBatchCompletes() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 2 + stubUpdateNotificationContext() + + handler.onInstallFinished(appInstall, true) + + verify { updatesNotificationSender.showNotification("Update", "Updated message") } + verify { updatesTracker.clearSuccessfullyUpdatedApps() } + } + + @Test + fun onInstallFinished_ignoresIssueAndPurchaseNeededStatusesForCompletion() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + appInstallRepository.addDownload( + AppInstall( + id = "issue", + status = Status.INSTALLATION_ISSUE, + packageName = "com.example.issue" + ) + ) + appInstallRepository.addDownload( + AppInstall( + id = "purchase", + status = Status.PURCHASE_NEEDED, + packageName = "com.example.purchase" + ) + ) + every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 1 + stubUpdateNotificationContext() + + handler.onInstallFinished(appInstall, true) + + verify { updatesNotificationSender.showNotification("Update", "Updated message") } + } + + @Test + fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED + every { updatesTracker.hasSuccessfulUpdatedApps() } returns true + every { updatesTracker.successfulUpdatedAppsCount() } returns 1 + stubUpdateNotificationContext() + + handler.onInstallFinished(appInstall, true) + + verify { updatesTracker.clearSuccessfullyUpdatedApps() } + } + + private suspend fun stubUpdateNotificationContext() { + val authData = AuthData(email = "user@example.com", isAnonymous = false).apply { + locale = Locale.US + } + coEvery { playStoreAuthStore.awaitAuthData() } returns authData + every { context.getString(R.string.update) } returns "Update" + every { + context.getString( + R.string.message_last_update_triggered, + any(), + any() + ) + } returns "Updated message" + } +} -- GitLab From f8ed48d327c94b60adca3a6c6d95c7422a2eadf3 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 22:32:57 +0600 Subject: [PATCH 04/10] refactor: remove shared update state from AppInstallProcessor Pass the update-work flag through the install flow explicitly so the async worker path no longer depends on mutable processor state between callbacks. --- .../workmanager/AppInstallProcessor.kt | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) 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 aafc42758..ecb37ad9c 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 @@ -73,8 +73,6 @@ class AppInstallProcessor @Inject constructor( @Inject lateinit var appManager: AppManager - private var isItUpdateWork = false - companion object { private const val TAG = "AppInstallProcessor" } @@ -299,7 +297,7 @@ class AppInstallProcessor @Inject constructor( appInstall?.let { checkDownloadingState(appInstall) - this.isItUpdateWork = + val isUpdateWork = isItUpdateWork && appInstallComponents.appManagerWrapper.isFusedDownloadInstalled( appInstall ) @@ -325,7 +323,7 @@ class AppInstallProcessor @Inject constructor( runInForeground.invoke(it.name) - startAppInstallationProcess(it) + startAppInstallationProcess(it, isUpdateWork) } } catch (e: Exception) { Timber.e( @@ -355,15 +353,13 @@ class AppInstallProcessor @Inject constructor( appInstall ) || appInstall.status == Status.INSTALLING) - private suspend fun checkUpdateWork( - appInstall: AppInstall? - ) { - appUpdateCompletionHandler.onInstallFinished(appInstall, isItUpdateWork) + private suspend fun checkUpdateWork(appInstall: AppInstall?, isUpdateWork: Boolean) { + appUpdateCompletionHandler.onInstallFinished(appInstall, isUpdateWork) } - private suspend fun startAppInstallationProcess(appInstall: AppInstall) { + private suspend fun startAppInstallationProcess(appInstall: AppInstall, isUpdateWork: Boolean) { if (appInstall.isAwaiting()) { - appInstallComponents.appManagerWrapper.downloadApp(appInstall, isItUpdateWork) + appInstallComponents.appManagerWrapper.downloadApp(appInstall, isUpdateWork) Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}") } @@ -373,7 +369,7 @@ class AppInstallProcessor @Inject constructor( isInstallRunning(it) } .collect { latestFusedDownload -> - handleFusedDownload(latestFusedDownload, appInstall) + handleFusedDownload(latestFusedDownload, appInstall, isUpdateWork) } } @@ -386,35 +382,37 @@ class AppInstallProcessor @Inject constructor( */ private suspend fun handleFusedDownload( latestAppInstall: AppInstall?, - appInstall: AppInstall + appInstall: AppInstall, + isUpdateWork: Boolean ) { if (latestAppInstall == null) { Timber.d("===> download null: finish installation") - finishInstallation(appInstall) + finishInstallation(appInstall, isUpdateWork) return } - handleFusedDownloadStatusCheckingException(latestAppInstall) + handleFusedDownloadStatusCheckingException(latestAppInstall, isUpdateWork) } private fun isInstallRunning(it: AppInstall?) = it != null && it.status != Status.INSTALLATION_ISSUE private suspend fun handleFusedDownloadStatusCheckingException( - download: AppInstall + download: AppInstall, + isUpdateWork: Boolean ) { try { - handleFusedDownloadStatus(download) + handleFusedDownloadStatus(download, isUpdateWork) } catch (e: Exception) { val message = "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}" Timber.e(e, message) appInstallComponents.appManagerWrapper.installationIssue(download) - finishInstallation(download) + finishInstallation(download, isUpdateWork) } } - private suspend fun handleFusedDownloadStatus(appInstall: AppInstall) { + private suspend fun handleFusedDownloadStatus(appInstall: AppInstall, isUpdateWork: Boolean) { when (appInstall.status) { Status.AWAITING, Status.DOWNLOADING -> { } @@ -432,7 +430,7 @@ class AppInstallProcessor @Inject constructor( Status.INSTALLED, Status.INSTALLATION_ISSUE -> { Timber.i("===> doWork: Installed/Failed: ${appInstall.name} ${appInstall.status}") - finishInstallation(appInstall) + finishInstallation(appInstall, isUpdateWork) } else -> { @@ -440,12 +438,12 @@ class AppInstallProcessor @Inject constructor( TAG, "===> ${appInstall.name} is in wrong state ${appInstall.status}" ) - finishInstallation(appInstall) + finishInstallation(appInstall, isUpdateWork) } } } - private suspend fun finishInstallation(appInstall: AppInstall) { - checkUpdateWork(appInstall) + private suspend fun finishInstallation(appInstall: AppInstall, isUpdateWork: Boolean) { + checkUpdateWork(appInstall, isUpdateWork) } } -- GitLab From 1ca209e1976752213d2a4ddc7a819d10c7176a70 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 23:25:29 +0600 Subject: [PATCH 05/10] refactor: extract AppInstallProcessor age validation flow into separate class Move the age-limit dialog and parental-auth checks behind AppInstallAgeLimitGate so enqueue validation keeps the current order while the restriction flow gains direct unit coverage. --- .../workmanager/AppInstallAgeLimitGate.kt | 70 +++++++++ .../workmanager/AppInstallProcessor.kt | 40 +---- .../AppInstallAgeLimitGateTest.kt | 140 ++++++++++++++++++ .../AppInstallProcessorTest.kt | 35 +++-- .../FakeAppEventDispatcher.kt | 35 +++++ 5 files changed, 266 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt new file mode 100644 index 000000000..56cabf5fe --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt @@ -0,0 +1,70 @@ +/* + * 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 foundation.e.apps.R +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway +import foundation.e.apps.domain.ValidateAppAgeLimitUseCase +import foundation.e.apps.domain.model.ContentRatingValidity +import kotlinx.coroutines.CompletableDeferred +import javax.inject.Inject + +@Suppress("ReturnCount") // FIXME: Remove suppression and fix detekt +class AppInstallAgeLimitGate @Inject constructor( + private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase, + private val appManagerWrapper: AppManagerWrapper, + private val appEventDispatcher: AppEventDispatcher, + private val parentalControlAuthGateway: ParentalControlAuthGateway, +) { + suspend fun allow(appInstall: AppInstall): Boolean { + val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall) + if (ageLimitValidationResult.data?.isValid == true) { + return true + } + + if (ageLimitValidationResult.isSuccess()) { + awaitInvokeAgeLimitEvent(appInstall.name) + if (ageLimitValidationResult.data?.requestPin == true) { + val isAuthenticated = parentalControlAuthGateway.awaitAuthentication() + if (isAuthenticated) { + ageLimitValidationResult.setData(ContentRatingValidity(true)) + } + } + } else { + appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) + } + + if (ageLimitValidationResult.data?.isValid == true) { + return true + } + + appManagerWrapper.cancelDownload(appInstall) + return false + } + + private suspend fun awaitInvokeAgeLimitEvent(type: String) { + val deferred = CompletableDeferred() + appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred)) + deferred.await() + } +} 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 ecb37ad9c..3858c4c2b 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 @@ -38,15 +38,11 @@ import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher import foundation.e.apps.data.install.wrapper.NetworkStatusChecker -import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway import foundation.e.apps.data.install.wrapper.StorageSpaceChecker import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.domain.ValidateAppAgeLimitUseCase -import foundation.e.apps.domain.model.ContentRatingValidity import foundation.e.apps.domain.model.User import foundation.e.apps.domain.preferences.SessionRepository -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.transformWhile import timber.log.Timber @@ -57,14 +53,13 @@ class AppInstallProcessor @Inject constructor( @ApplicationContext private val context: Context, private val appInstallComponents: AppInstallComponents, private val applicationRepository: ApplicationRepository, - private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase, private val sessionRepository: SessionRepository, private val playStoreAuthStore: PlayStoreAuthStore, private val storageNotificationManager: StorageNotificationManager, private val appEventDispatcher: AppEventDispatcher, private val storageSpaceChecker: StorageSpaceChecker, - private val parentalControlAuthGateway: ParentalControlAuthGateway, private val networkStatusChecker: NetworkStatusChecker, + private val appInstallAgeLimitGate: AppInstallAgeLimitGate, private val appUpdateCompletionHandler: AppUpdateCompletionHandler, ) { @Inject @@ -166,7 +161,7 @@ class AppInstallProcessor @Inject constructor( return false } - if (!validateAgeLimit(appInstall)) { + if (!appInstallAgeLimitGate.allow(appInstall)) { return false } @@ -187,37 +182,6 @@ class AppInstallProcessor @Inject constructor( return true } - private suspend fun validateAgeLimit(appInstall: AppInstall): Boolean { - val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall) - if (ageLimitValidationResult.data?.isValid == true) { - return true - } - if (ageLimitValidationResult.isSuccess()) { - awaitInvokeAgeLimitEvent(appInstall.name) - if (ageLimitValidationResult.data?.requestPin == true) { - val isAuthenticated = parentalControlAuthGateway.awaitAuthentication() - if (isAuthenticated) { - ageLimitValidationResult.setData(ContentRatingValidity(true)) - } - } - } else { - appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) - } - - var ageIsValid = true - if (ageLimitValidationResult.data?.isValid != true) { - appInstallComponents.appManagerWrapper.cancelDownload(appInstall) - ageIsValid = false - } - return ageIsValid - } - - suspend fun awaitInvokeAgeLimitEvent(type: String) { - val deferred = CompletableDeferred() - appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred)) - deferred.await() // await closing dialog box - } - // returns TRUE if updating urls is successful, otherwise false. private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean { try { diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt new file mode 100644 index 000000000..19de4f0f8 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt @@ -0,0 +1,140 @@ +/* + * 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.installProcessor + +import foundation.e.apps.R +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate +import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway +import foundation.e.apps.domain.ValidateAppAgeLimitUseCase +import foundation.e.apps.domain.model.ContentRatingValidity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +@OptIn(ExperimentalCoroutinesApi::class) +class AppInstallAgeLimitGateTest { + private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var gate: AppInstallAgeLimitGate + + @Before + fun setup() { + validateAppAgeLimitUseCase = Mockito.mock(ValidateAppAgeLimitUseCase::class.java) + appManagerWrapper = mockk(relaxed = true) + parentalControlAuthGateway = mockk(relaxed = true) + appEventDispatcher = FakeAppEventDispatcher(autoCompleteDeferred = true) + gate = AppInstallAgeLimitGate( + validateAppAgeLimitUseCase, + appManagerWrapper, + appEventDispatcher, + parentalControlAuthGateway + ) + } + + @Test + fun allow_returnsTrueWhenAgeRatingIsValid() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + Mockito.`when`(validateAppAgeLimitUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) + + val result = gate.allow(appInstall) + + assertTrue(result) + coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) } + } + + @Test + fun allow_returnsFalseAndCancelsWhenAgeRatingInvalidWithoutPin() = runTest { + val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app") + Mockito.`when`(validateAppAgeLimitUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false))) + + val result = gate.allow(appInstall) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent }) + coVerify { appManagerWrapper.cancelDownload(appInstall) } + } + + @Test + fun allow_returnsTrueWhenPinIsRequiredAndAuthenticationSucceeds() = runTest { + val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app") + Mockito.`when`(validateAppAgeLimitUseCase(appInstall)) + .thenReturn( + ResultSupreme.create( + ResultStatus.OK, + ContentRatingValidity(false, requestPin = true) + ) + ) + coEvery { parentalControlAuthGateway.awaitAuthentication() } returns true + + val result = gate.allow(appInstall) + + assertTrue(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent }) + coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) } + } + + @Test + fun allow_returnsFalseWhenPinIsRequiredAndAuthenticationFails() = runTest { + val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app") + Mockito.`when`(validateAppAgeLimitUseCase(appInstall)) + .thenReturn( + ResultSupreme.create( + ResultStatus.OK, + ContentRatingValidity(false, requestPin = true) + ) + ) + coEvery { parentalControlAuthGateway.awaitAuthentication() } returns false + + val result = gate.allow(appInstall) + + assertFalse(result) + coVerify { appManagerWrapper.cancelDownload(appInstall) } + } + + @Test + fun allow_dispatchesErrorDialogWhenValidationFails() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + Mockito.`when`(validateAppAgeLimitUseCase(appInstall)) + .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) + + val result = gate.allow(appInstall) + + assertFalse(result) + val errorDialogEvent = appEventDispatcher.events.last() as AppEvent.ErrorMessageDialogEvent + assertEquals(R.string.data_load_error_desc, errorDialogEvent.data) + coVerify { appManagerWrapper.cancelDownload(appInstall) } + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 9d3460510..dc7249103 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -35,7 +35,8 @@ import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate +import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.NetworkStatusChecker @@ -43,7 +44,6 @@ import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway import foundation.e.apps.data.install.wrapper.StorageSpaceChecker import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender import foundation.e.apps.data.install.wrapper.UpdatesTracker -import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.domain.ValidateAppAgeLimitUseCase @@ -109,12 +109,13 @@ class AppInstallProcessorTest { @Mock private lateinit var storageNotificationManager: StorageNotificationManager - private lateinit var appEventDispatcher: RecordingAppEventDispatcher + private lateinit var appEventDispatcher: FakeAppEventDispatcher private lateinit var storageSpaceChecker: StorageSpaceChecker private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway private lateinit var updatesTracker: UpdatesTracker private lateinit var updatesNotificationSender: UpdatesNotificationSender private lateinit var networkStatusChecker: NetworkStatusChecker + private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler @Before @@ -126,7 +127,7 @@ class AppInstallProcessorTest { applicationRepository = mockk(relaxed = true) coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE coEvery { playStoreAuthStore.awaitAuthData() } returns null - appEventDispatcher = RecordingAppEventDispatcher() + appEventDispatcher = FakeAppEventDispatcher() storageSpaceChecker = mockk(relaxed = true) parentalControlAuthGateway = mockk(relaxed = true) updatesTracker = mockk(relaxed = true) @@ -138,6 +139,12 @@ class AppInstallProcessorTest { FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository) val appInstallComponents = AppInstallComponents(appInstallRepository, fakeFusedManagerRepository) + appInstallAgeLimitGate = AppInstallAgeLimitGate( + validateAppAgeRatingUseCase, + fakeFusedManagerRepository, + appEventDispatcher, + parentalControlAuthGateway + ) appUpdateCompletionHandler = AppUpdateCompletionHandler( context, appInstallRepository, @@ -151,14 +158,13 @@ class AppInstallProcessorTest { context, appInstallComponents, applicationRepository, - validateAppAgeRatingUseCase, sessionRepository, playStoreAuthStore, storageNotificationManager, appEventDispatcher, storageSpaceChecker, - parentalControlAuthGateway, networkStatusChecker, + appInstallAgeLimitGate, appUpdateCompletionHandler ) } @@ -627,27 +633,24 @@ class AppInstallProcessorTest { ): AppInstallProcessor { val appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) + val ageLimitGate = AppInstallAgeLimitGate( + validateAppAgeRatingUseCase, + appManagerWrapper, + appEventDispatcher, + parentalControlAuthGateway + ) return AppInstallProcessor( context, appInstallComponents, applicationRepository, - validateAppAgeRatingUseCase, sessionRepository, playStoreAuthStore, storageNotificationManager, appEventDispatcher, storageSpaceChecker, - parentalControlAuthGateway, networkStatusChecker, + ageLimitGate, appUpdateCompletionHandler ) } } - -private class RecordingAppEventDispatcher : AppEventDispatcher { - val events = mutableListOf() - - override suspend fun dispatch(event: AppEvent) { - events.add(event) - } -} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt new file mode 100644 index 000000000..b9ff56e85 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt @@ -0,0 +1,35 @@ +/* + * 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.installProcessor + +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.wrapper.AppEventDispatcher + +internal class FakeAppEventDispatcher( + private val autoCompleteDeferred: Boolean = false +) : AppEventDispatcher { + val events = mutableListOf() + + override suspend fun dispatch(event: AppEvent) { + events.add(event) + if (autoCompleteDeferred && event is AppEvent.AgeLimitRestrictionEvent) { + event.onClose?.complete(Unit) + } + } +} -- GitLab From 5abb003e68c17713ac238393d5b8f6701d54e412 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 23:40:59 +0600 Subject: [PATCH 06/10] refactor: move the install-state machine from AppInstallProcessor to AppInstallWorkRunner Move the install-state machine and flow handling into a dedicated runner so worker behavior can be tested directly while the processor keeps the same external contract. --- .../workmanager/AppInstallProcessor.kt | 170 +------------- .../workmanager/AppInstallWorkRunner.kt | 176 +++++++++++++++ .../AppInstallProcessorTest.kt | 22 +- .../AppInstallWorkRunnerTest.kt | 209 ++++++++++++++++++ 4 files changed, 406 insertions(+), 171 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt 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 3858c4c2b..81bc319d1 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 @@ -33,7 +33,6 @@ import foundation.e.apps.data.enums.Type import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.AppManager -import foundation.e.apps.data.install.download.DownloadManagerUtils import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher @@ -44,7 +43,6 @@ import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.domain.model.User import foundation.e.apps.domain.preferences.SessionRepository import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.flow.transformWhile import timber.log.Timber import javax.inject.Inject @@ -60,18 +58,11 @@ class AppInstallProcessor @Inject constructor( private val storageSpaceChecker: StorageSpaceChecker, private val networkStatusChecker: NetworkStatusChecker, private val appInstallAgeLimitGate: AppInstallAgeLimitGate, - private val appUpdateCompletionHandler: AppUpdateCompletionHandler, + private val appInstallWorkRunner: AppInstallWorkRunner, ) { - @Inject - lateinit var downloadManager: DownloadManagerUtils - @Inject lateinit var appManager: AppManager - companion object { - private const val TAG = "AppInstallProcessor" - } - /** * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process. * @param application represents the app info which will be installed @@ -251,163 +242,6 @@ class AppInstallProcessor @Inject constructor( isItUpdateWork: Boolean, runInForeground: (suspend (String) -> Unit) ): Result { - var appInstall: AppInstall? = null - try { - Timber.d("Fused download name $fusedDownloadId") - - appInstall = appInstallComponents.appInstallRepository.getDownloadById(fusedDownloadId) - Timber.i(">>> dowork started for Fused download name " + appInstall?.name + " " + fusedDownloadId) - - appInstall?.let { - checkDownloadingState(appInstall) - - val isUpdateWork = - isItUpdateWork && appInstallComponents.appManagerWrapper.isFusedDownloadInstalled( - appInstall - ) - - if (!appInstall.isAppInstalling()) { - Timber.d("!!! returned") - return@let - } - - if (!appInstallComponents.appManagerWrapper.validateFusedDownload(appInstall)) { - appInstallComponents.appManagerWrapper.installationIssue(it) - Timber.d("!!! installationIssue") - return@let - } - - if (areFilesDownloadedButNotInstalled(appInstall)) { - Timber.i("===> Downloaded But not installed ${appInstall.name}") - appInstallComponents.appManagerWrapper.updateDownloadStatus( - appInstall, - Status.INSTALLING - ) - } - - runInForeground.invoke(it.name) - - startAppInstallationProcess(it, isUpdateWork) - } - } catch (e: Exception) { - Timber.e( - e, - "Install worker is failed for ${appInstall?.packageName} exception: ${e.localizedMessage}" - ) - appInstall?.let { - appInstallComponents.appManagerWrapper.cancelDownload(appInstall) - } - } - - Timber.i("doWork: RESULT SUCCESS: ${appInstall?.name}") - return Result.success(ResultStatus.OK) - } - - @OptIn(DelicateCoroutinesApi::class) - private fun checkDownloadingState(appInstall: AppInstall) { - if (appInstall.status == Status.DOWNLOADING) { - appInstall.downloadIdMap.keys.forEach { downloadId -> - downloadManager.updateDownloadStatus(downloadId) - } - } - } - - private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall) = - appInstall.areFilesDownloaded() && (!appInstallComponents.appManagerWrapper.isFusedDownloadInstalled( - appInstall - ) || appInstall.status == Status.INSTALLING) - - private suspend fun checkUpdateWork(appInstall: AppInstall?, isUpdateWork: Boolean) { - appUpdateCompletionHandler.onInstallFinished(appInstall, isUpdateWork) - } - - private suspend fun startAppInstallationProcess(appInstall: AppInstall, isUpdateWork: Boolean) { - if (appInstall.isAwaiting()) { - appInstallComponents.appManagerWrapper.downloadApp(appInstall, isUpdateWork) - Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}") - } - - appInstallComponents.appInstallRepository.getDownloadFlowById(appInstall.id) - .transformWhile { - emit(it) - isInstallRunning(it) - } - .collect { latestFusedDownload -> - handleFusedDownload(latestFusedDownload, appInstall, isUpdateWork) - } - } - - /** - * Takes actions depending on the status of [AppInstall] - * - * @param latestAppInstall comes from Room database when [Status] is updated - * @param appInstall is the original object when install process isn't started. It's used when [latestAppInstall] - * becomes null, After installation is completed. - */ - private suspend fun handleFusedDownload( - latestAppInstall: AppInstall?, - appInstall: AppInstall, - isUpdateWork: Boolean - ) { - if (latestAppInstall == null) { - Timber.d("===> download null: finish installation") - finishInstallation(appInstall, isUpdateWork) - return - } - - handleFusedDownloadStatusCheckingException(latestAppInstall, isUpdateWork) - } - - private fun isInstallRunning(it: AppInstall?) = - it != null && it.status != Status.INSTALLATION_ISSUE - - private suspend fun handleFusedDownloadStatusCheckingException( - download: AppInstall, - isUpdateWork: Boolean - ) { - try { - handleFusedDownloadStatus(download, isUpdateWork) - } catch (e: Exception) { - val message = - "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}" - Timber.e(e, message) - appInstallComponents.appManagerWrapper.installationIssue(download) - finishInstallation(download, isUpdateWork) - } - } - - private suspend fun handleFusedDownloadStatus(appInstall: AppInstall, isUpdateWork: Boolean) { - when (appInstall.status) { - Status.AWAITING, Status.DOWNLOADING -> { - } - - Status.DOWNLOADED -> { - appInstallComponents.appManagerWrapper.updateDownloadStatus( - appInstall, - Status.INSTALLING - ) - } - - Status.INSTALLING -> { - Timber.i("===> doWork: Installing ${appInstall.name} ${appInstall.status}") - } - - Status.INSTALLED, Status.INSTALLATION_ISSUE -> { - Timber.i("===> doWork: Installed/Failed: ${appInstall.name} ${appInstall.status}") - finishInstallation(appInstall, isUpdateWork) - } - - else -> { - Timber.wtf( - TAG, - "===> ${appInstall.name} is in wrong state ${appInstall.status}" - ) - finishInstallation(appInstall, isUpdateWork) - } - } - } - - private suspend fun finishInstallation(appInstall: AppInstall, isUpdateWork: Boolean) { - checkUpdateWork(appInstall, isUpdateWork) + return appInstallWorkRunner.processInstall(fusedDownloadId, isItUpdateWork, runInForeground) } } diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt new file mode 100644 index 000000000..2d9c73f7d --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt @@ -0,0 +1,176 @@ +/* + * 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 foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.download.DownloadManagerUtils +import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.flow.transformWhile +import timber.log.Timber +import javax.inject.Inject + +@Suppress("TooGenericExceptionCaught") // FIXME: Remove suppression and fix detekt +class AppInstallWorkRunner @Inject constructor( + private val appInstallRepository: AppInstallRepository, + private val appManagerWrapper: AppManagerWrapper, + private val downloadManager: DownloadManagerUtils, + private val appUpdateCompletionHandler: AppUpdateCompletionHandler, +) { + @OptIn(DelicateCoroutinesApi::class) + suspend fun processInstall( + fusedDownloadId: String, + isItUpdateWork: Boolean, + runInForeground: suspend (String) -> Unit + ): Result { + var appInstall: AppInstall? = null + try { + Timber.d("Fused download name $fusedDownloadId") + + appInstall = appInstallRepository.getDownloadById(fusedDownloadId) + Timber.i(">>> doWork started for Fused download name ${appInstall?.name} $fusedDownloadId") + + appInstall?.let { + checkDownloadingState(appInstall) + + val isUpdateWork = + isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(appInstall) + + if (!appInstall.isAppInstalling()) { + Timber.d("!!! returned") + return@let + } + + if (!appManagerWrapper.validateFusedDownload(appInstall)) { + appManagerWrapper.installationIssue(it) + Timber.d("!!! installationIssue") + return@let + } + + if (areFilesDownloadedButNotInstalled(appInstall)) { + Timber.i("===> Downloaded But not installed ${appInstall.name}") + appManagerWrapper.updateDownloadStatus(appInstall, Status.INSTALLING) + } + + runInForeground.invoke(it.name) + + startAppInstallationProcess(it, isUpdateWork) + } + } catch (e: Exception) { + Timber.e( + e, + "Install worker is failed for ${appInstall?.packageName} exception: ${e.localizedMessage}" + ) + appInstall?.let { + appManagerWrapper.cancelDownload(appInstall) + } + } + + Timber.i("doWork: RESULT SUCCESS: ${appInstall?.name}") + return Result.success(ResultStatus.OK) + } + + @OptIn(DelicateCoroutinesApi::class) + private fun checkDownloadingState(appInstall: AppInstall) { + if (appInstall.status == Status.DOWNLOADING) { + appInstall.downloadIdMap.keys.forEach { downloadId -> + downloadManager.updateDownloadStatus(downloadId) + } + } + } + + private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall): Boolean = appInstall.areFilesDownloaded() && + (!appManagerWrapper.isFusedDownloadInstalled(appInstall) || appInstall.status == Status.INSTALLING) + + private suspend fun startAppInstallationProcess(appInstall: AppInstall, isUpdateWork: Boolean) { + if (appInstall.isAwaiting()) { + appManagerWrapper.downloadApp(appInstall, isUpdateWork) + Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}") + } + + appInstallRepository.getDownloadFlowById(appInstall.id) + .transformWhile { + emit(it) + isInstallRunning(it) + } + .collect { latestFusedDownload -> + handleFusedDownload(latestFusedDownload, appInstall, isUpdateWork) + } + } + + private suspend fun handleFusedDownload( + latestAppInstall: AppInstall?, + appInstall: AppInstall, + isUpdateWork: Boolean + ) { + if (latestAppInstall == null) { + Timber.d("===> download null: finish installation") + finishInstallation(appInstall, isUpdateWork) + return + } + + handleFusedDownloadStatusCheckingException(latestAppInstall, isUpdateWork) + } + + private fun isInstallRunning(it: AppInstall?) = + it != null && it.status != Status.INSTALLATION_ISSUE + + private suspend fun handleFusedDownloadStatusCheckingException( + download: AppInstall, + isUpdateWork: Boolean + ) { + try { + handleFusedDownloadStatus(download, isUpdateWork) + } catch (e: Exception) { + val message = + "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}" + Timber.e(e, message) + appManagerWrapper.installationIssue(download) + finishInstallation(download, isUpdateWork) + } + } + + private suspend fun handleFusedDownloadStatus(appInstall: AppInstall, isUpdateWork: Boolean) { + when (appInstall.status) { + Status.AWAITING, Status.DOWNLOADING -> Unit + Status.DOWNLOADED -> appManagerWrapper.updateDownloadStatus( + appInstall, + Status.INSTALLING + ) + + Status.INSTALLING -> Timber.i("===> doWork: Installing ${appInstall.name} ${appInstall.status}") + Status.INSTALLED, Status.INSTALLATION_ISSUE -> { + Timber.i("===> doWork: Installed/Failed: ${appInstall.name} ${appInstall.status}") + finishInstallation(appInstall, isUpdateWork) + } + + else -> { + Timber.w("===> ${appInstall.name} is in wrong state ${appInstall.status}") + finishInstallation(appInstall, isUpdateWork) + } + } + } + + private suspend fun finishInstallation(appInstall: AppInstall, isUpdateWork: Boolean) { + appUpdateCompletionHandler.onInstallFinished(appInstall, isUpdateWork) + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index dc7249103..739c133a4 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -21,8 +21,8 @@ package foundation.e.apps.installProcessor import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.aurora.gplayapi.data.models.AuthData -import foundation.e.apps.data.ResultSupreme import foundation.e.apps.R +import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source @@ -37,6 +37,7 @@ import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.NetworkStatusChecker @@ -117,6 +118,7 @@ class AppInstallProcessorTest { private lateinit var networkStatusChecker: NetworkStatusChecker private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler + private lateinit var appInstallWorkRunner: AppInstallWorkRunner @Before fun setup() { @@ -153,6 +155,14 @@ class AppInstallProcessorTest { updatesTracker, updatesNotificationSender ) + val downloadManager = + mockk(relaxed = true) + appInstallWorkRunner = AppInstallWorkRunner( + appInstallRepository, + fakeFusedManagerRepository, + downloadManager, + appUpdateCompletionHandler + ) appInstallProcessor = AppInstallProcessor( context, @@ -165,7 +175,7 @@ class AppInstallProcessorTest { storageSpaceChecker, networkStatusChecker, appInstallAgeLimitGate, - appUpdateCompletionHandler + appInstallWorkRunner ) } @@ -639,6 +649,12 @@ class AppInstallProcessorTest { appEventDispatcher, parentalControlAuthGateway ) + val workRunner = AppInstallWorkRunner( + appInstallRepository, + appManagerWrapper, + mockk(relaxed = true), + appUpdateCompletionHandler + ) return AppInstallProcessor( context, appInstallComponents, @@ -650,7 +666,7 @@ class AppInstallProcessorTest { storageSpaceChecker, networkStatusChecker, ageLimitGate, - appUpdateCompletionHandler + workRunner ) } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt new file mode 100644 index 000000000..bc446e858 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt @@ -0,0 +1,209 @@ +/* + * 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.installProcessor + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.fdroid.FDroidRepository +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManager +import foundation.e.apps.data.install.download.DownloadManagerUtils +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner +import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +class AppInstallWorkRunnerTest { + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var fakeFusedDownloadDAO: FakeAppInstallDAO + private lateinit var appInstallRepository: AppInstallRepository + private lateinit var fakeFusedManagerRepository: FakeAppManagerWrapper + private lateinit var downloadManagerUtils: DownloadManagerUtils + private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler + private lateinit var workRunner: AppInstallWorkRunner + private lateinit var context: Context + + @Mock + private lateinit var fakeFusedManager: AppManager + + @Mock + private lateinit var fakeFDroidRepository: FDroidRepository + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + context = mockk(relaxed = true) + fakeFusedDownloadDAO = FakeAppInstallDAO() + appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO) + fakeFusedManagerRepository = + FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository) + downloadManagerUtils = mockk(relaxed = true) + appUpdateCompletionHandler = mockk(relaxed = true) + workRunner = AppInstallWorkRunner( + appInstallRepository, + fakeFusedManagerRepository, + downloadManagerUtils, + appUpdateCompletionHandler + ) + } + + @Test + fun processInstall_completesNormalFlow() = runTest { + val fusedDownload = initTest() + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertTrue(finalFusedDownload == null) + } + + @Test + fun processInstall_keepsBlockedDownloadUntouched() = runTest { + val fusedDownload = initTest() + fusedDownload.status = Status.BLOCKED + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertEquals(Status.BLOCKED, finalFusedDownload?.status) + } + + @Test + fun processInstall_marksDownloadedFilesAsInstalling() = runTest { + val fusedDownload = initTest() + fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true)) + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertTrue(finalFusedDownload == null) + } + + @Test + fun processInstall_reportsInvalidPackageAsInstallationIssue() = runTest { + val fusedDownload = initTest(packageName = "") + fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true)) + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status) + } + + @Test + fun processInstall_reportsMissingDownloadUrlsAsInstallationIssue() = runTest { + val fusedDownload = initTest(downloadUrlList = mutableListOf()) + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status) + } + + @Test + fun processInstall_returnsSuccessWhenInternalExceptionOccurs() = runTest { + val fusedDownload = initTest() + fakeFusedManagerRepository.forceCrash = true + + val result = workRunner.processInstall(fusedDownload.id, false) { + // _ignored_ + } + val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id) + + assertTrue(result.isSuccess) + assertEquals(ResultStatus.OK, result.getOrNull()) + assertTrue(finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE) + } + + @Test + fun processInstall_reportsDownloadFailure() = runTest { + val fusedDownload = initTest() + fakeFusedManagerRepository.willDownloadFail = true + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status) + } + + @Test + fun processInstall_reportsInstallFailure() = runTest { + val fusedDownload = initTest() + fakeFusedManagerRepository.willInstallFail = true + + val finalFusedDownload = runProcessInstall(fusedDownload) + + assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status) + } + + @Test + fun processInstall_updatesDownloadManagerStateForDownloadingItems() = runTest { + val fusedDownload = initTest() + fusedDownload.status = Status.DOWNLOADING + fusedDownload.downloadURLList = mutableListOf() + fusedDownload.downloadIdMap = mutableMapOf(231L to false, 232L to false) + + workRunner.processInstall(fusedDownload.id, false) { + // _ignored_ + } + + verify { downloadManagerUtils.updateDownloadStatus(231L) } + verify { downloadManagerUtils.updateDownloadStatus(232L) } + } + + private suspend fun initTest( + packageName: String? = null, + downloadUrlList: MutableList? = null + ): AppInstall { + val fusedDownload = createFusedDownload(packageName, downloadUrlList) + fakeFusedDownloadDAO.addDownload(fusedDownload) + return fusedDownload + } + + private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { + workRunner.processInstall(appInstall.id, false) { + // _ignored_ + } + return fakeFusedDownloadDAO.getDownloadById(appInstall.id) + } + + private fun createFusedDownload( + packageName: String? = null, + downloadUrlList: MutableList? = null + ) = AppInstall( + id = "121", + status = Status.AWAITING, + downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"), + packageName = packageName ?: "com.unit.test" + ) +} -- GitLab From d49e0d96c4966650962f3efa69d80ec6146c8a83 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 12 Mar 2026 23:49:45 +0600 Subject: [PATCH 07/10] refactor: move Application -> AppInstall conversion logic to AppInstallRequestFactory Move AppInstall construction into a focused factory so request mapping can be tested directly without changing how update detection or enqueueing works. --- .../workmanager/AppInstallProcessor.kt | 27 +---- .../workmanager/AppInstallRequestFactory.kt | 54 +++++++++ .../AppInstallProcessorTest.kt | 9 +- .../AppInstallRequestFactoryTest.kt | 108 ++++++++++++++++++ 4 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt 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 81bc319d1..b462f4389 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 @@ -27,7 +27,6 @@ import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus -import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.data.enums.Type import foundation.e.apps.data.event.AppEvent @@ -46,7 +45,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList") // FIXME: Remove suppression and fix detekt class AppInstallProcessor @Inject constructor( @ApplicationContext private val context: Context, private val appInstallComponents: AppInstallComponents, @@ -59,6 +58,7 @@ class AppInstallProcessor @Inject constructor( private val networkStatusChecker: NetworkStatusChecker, private val appInstallAgeLimitGate: AppInstallAgeLimitGate, private val appInstallWorkRunner: AppInstallWorkRunner, + private val appInstallRequestFactory: AppInstallRequestFactory, ) { @Inject lateinit var appManager: AppManager @@ -73,28 +73,7 @@ class AppInstallProcessor @Inject constructor( application: Application, isAnUpdate: Boolean = false ): Boolean { - val appInstall = AppInstall( - application._id, - application.source, - application.status, - application.name, - application.package_name, - mutableListOf(), - mutableMapOf(), - application.status, - application.type, - application.icon_image_path, - application.latest_version_code, - application.offer_type, - application.isFree, - application.originalSize - ).also { - it.contentRating = application.contentRating - } - - if (appInstall.type == Type.PWA || application.source == Source.SYSTEM_APP) { - appInstall.downloadURLList = mutableListOf(application.url) - } + val appInstall = appInstallRequestFactory.create(application) val isUpdate = isAnUpdate || application.status == Status.UPDATABLE || diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt new file mode 100644 index 000000000..2045c39bf --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt @@ -0,0 +1,54 @@ +/* + * 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 foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.install.models.AppInstall +import javax.inject.Inject + +class AppInstallRequestFactory @Inject constructor() { + fun create(application: Application): AppInstall { + val appInstall = AppInstall( + application._id, + application.source, + application.status, + application.name, + application.package_name, + mutableListOf(), + mutableMapOf(), + application.status, + application.type, + application.icon_image_path, + application.latest_version_code, + application.offer_type, + application.isFree, + application.originalSize + ).also { + it.contentRating = application.contentRating + } + + if (appInstall.type == Type.PWA || application.source == Source.SYSTEM_APP) { + appInstall.downloadURLList = mutableListOf(application.url) + } + + return appInstall + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index 739c133a4..ced8405d1 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -36,6 +36,7 @@ import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate +import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler @@ -119,6 +120,7 @@ class AppInstallProcessorTest { private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler private lateinit var appInstallWorkRunner: AppInstallWorkRunner + private lateinit var appInstallRequestFactory: AppInstallRequestFactory @Before fun setup() { @@ -130,6 +132,7 @@ class AppInstallProcessorTest { coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE coEvery { playStoreAuthStore.awaitAuthData() } returns null appEventDispatcher = FakeAppEventDispatcher() + appInstallRequestFactory = AppInstallRequestFactory() storageSpaceChecker = mockk(relaxed = true) parentalControlAuthGateway = mockk(relaxed = true) updatesTracker = mockk(relaxed = true) @@ -175,7 +178,8 @@ class AppInstallProcessorTest { storageSpaceChecker, networkStatusChecker, appInstallAgeLimitGate, - appInstallWorkRunner + appInstallWorkRunner, + appInstallRequestFactory ) } @@ -666,7 +670,8 @@ class AppInstallProcessorTest { storageSpaceChecker, networkStatusChecker, ageLimitGate, - workRunner + workRunner, + appInstallRequestFactory ) } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt new file mode 100644 index 000000000..261eabb20 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt @@ -0,0 +1,108 @@ +/* + * 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.installProcessor + +import com.aurora.gplayapi.data.models.ContentRating +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AppInstallRequestFactoryTest { + private lateinit var factory: AppInstallRequestFactory + + @Before + fun setup() { + factory = AppInstallRequestFactory() + } + + @Test + fun create_copiesExpectedFields() { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.AWAITING, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE, + icon_image_path = "icon.png", + latest_version_code = 42, + offer_type = 1, + isFree = false, + originalSize = 2048L + ) + + val appInstall = factory.create(application) + + assertEquals("123", appInstall.id) + assertEquals(Source.PLAY_STORE, appInstall.source) + assertEquals(Status.AWAITING, appInstall.status) + assertEquals("Example", appInstall.name) + assertEquals("com.example.app", appInstall.packageName) + assertEquals(Type.NATIVE, appInstall.type) + assertEquals("icon.png", appInstall.iconImageUrl) + assertEquals(42, appInstall.versionCode) + assertEquals(1, appInstall.offerType) + assertEquals(false, appInstall.isFree) + assertEquals(2048L, appInstall.appSize) + } + + @Test + fun create_setsContentRating() { + val contentRating = ContentRating() + val application = Application(contentRating = contentRating) + + val appInstall = factory.create(application) + + assertEquals(contentRating, appInstall.contentRating) + } + + @Test + fun create_initializesDirectUrlForPwa() { + val application = Application(type = Type.PWA, url = "https://example.com") + + val appInstall = factory.create(application) + + assertEquals(mutableListOf("https://example.com"), appInstall.downloadURLList) + } + + @Test + fun create_initializesDirectUrlForSystemApp() { + val application = Application(source = Source.SYSTEM_APP, url = "file://app.apk") + + val appInstall = factory.create(application) + + assertEquals(mutableListOf("file://app.apk"), appInstall.downloadURLList) + } + + @Test + fun create_doesNotForceDirectUrlForNativeNonSystemApp() { + val application = + Application(source = Source.PLAY_STORE, type = Type.NATIVE, url = "ignored") + + val appInstall = factory.create(application) + + assertTrue(appInstall.downloadURLList.isEmpty()) + } +} -- GitLab From 39346f176c567986d9efefd11731b462eaa275bd Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 13 Mar 2026 00:03:37 +0600 Subject: [PATCH 08/10] refactor: move pre-enqueue and enqueue logic from AppInstallProcessor to a separate class Move enqueue validation and download-link refresh orchestration behind a dedicated coordinator so preflight rules can be exercised directly while the processor stays focused on delegation. --- .../workmanager/AppInstallProcessor.kt | 150 +--------- .../workmanager/AppInstallStartCoordinator.kt | 179 +++++++++++ .../AppInstallProcessorTest.kt | 50 ++-- .../AppInstallStartCoordinatorTest.kt | 282 ++++++++++++++++++ 4 files changed, 495 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt create mode 100644 app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt 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 b462f4389..2e8f21bf4 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 @@ -18,51 +18,21 @@ package foundation.e.apps.data.install.workmanager -import android.content.Context import androidx.annotation.VisibleForTesting -import com.aurora.gplayapi.exceptions.InternalException -import dagger.hilt.android.qualifiers.ApplicationContext -import foundation.e.apps.R -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Status -import foundation.e.apps.data.enums.Type -import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.install.AppInstallComponents -import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.models.AppInstall -import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.wrapper.AppEventDispatcher -import foundation.e.apps.data.install.wrapper.NetworkStatusChecker -import foundation.e.apps.data.install.wrapper.StorageSpaceChecker -import foundation.e.apps.data.playstore.utils.GplayHttpRequestException -import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.domain.model.User -import foundation.e.apps.domain.preferences.SessionRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList") // FIXME: Remove suppression and fix detekt class AppInstallProcessor @Inject constructor( - @ApplicationContext private val context: Context, private val appInstallComponents: AppInstallComponents, - private val applicationRepository: ApplicationRepository, - private val sessionRepository: SessionRepository, - private val playStoreAuthStore: PlayStoreAuthStore, - private val storageNotificationManager: StorageNotificationManager, - private val appEventDispatcher: AppEventDispatcher, - private val storageSpaceChecker: StorageSpaceChecker, - private val networkStatusChecker: NetworkStatusChecker, - private val appInstallAgeLimitGate: AppInstallAgeLimitGate, + private val appInstallStartCoordinator: AppInstallStartCoordinator, private val appInstallWorkRunner: AppInstallWorkRunner, private val appInstallRequestFactory: AppInstallRequestFactory, ) { - @Inject - lateinit var appManager: AppManager - /** * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process. * @param application represents the app info which will be installed @@ -94,128 +64,14 @@ class AppInstallProcessor @Inject constructor( isAnUpdate: Boolean = false, isSystemApp: Boolean = false ): Boolean { - return try { - val user = sessionRepository.awaitUser() - if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { - val authData = playStoreAuthStore.awaitAuthData() - if (!appInstall.isFree && authData?.isAnonymous == true) { - appEventDispatcher.dispatch( - AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message) - ) - } - } - - if (!canEnqueue(appInstall)) return false - - appInstallComponents.appManagerWrapper.updateAwaiting(appInstall) - InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate) - true - } catch (e: Exception) { - Timber.e( - e, - "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}" - ) - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - false - } + return appInstallStartCoordinator.enqueue(appInstall, isAnUpdate, isSystemApp) } @VisibleForTesting suspend fun canEnqueue(appInstall: AppInstall): Boolean { - if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) { - return false - } - - if (!appInstallComponents.appManagerWrapper.addDownload(appInstall)) { - Timber.i("Update adding ABORTED! status") - return false - } - - if (!appInstallAgeLimitGate.allow(appInstall)) { - return false - } - - if (!networkStatusChecker.isNetworkAvailable()) { - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false)) - return false - } - - if (storageSpaceChecker.spaceMissing(appInstall) > 0) { - Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") - storageNotificationManager.showNotEnoughSpaceNotification(appInstall) - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) - return false - } - - return true - } - - // returns TRUE if updating urls is successful, otherwise false. - private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean { - try { - updateFusedDownloadWithAppDownloadLink(appInstall) - } catch (e: InternalException.AppNotPurchased) { - if (appInstall.isFree) { - handleAppRestricted(appInstall) - return false - } - appInstallComponents.appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) - appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) - return false - } catch (e: GplayHttpRequestException) { - handleUpdateDownloadError( - appInstall, - "${appInstall.packageName} code: ${e.status} exception: ${e.localizedMessage}", - e - ) - return false - } catch (e: IllegalStateException) { - Timber.e(e) - } catch (e: Exception) { - handleUpdateDownloadError( - appInstall, - "${appInstall.packageName} exception: ${e.localizedMessage}", - e - ) - return false - } - return true - } - - private suspend fun handleAppRestricted(appInstall: AppInstall) { - appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) - appManager.addDownload(appInstall) - appManager.updateUnavailable(appInstall) - } - - private suspend fun handleUpdateDownloadError( - appInstall: AppInstall, - message: String, - e: Exception - ) { - Timber.e(e, "Updating download Urls failed for $message") - appEventDispatcher.dispatch( - AppEvent.UpdateEvent( - ResultSupreme.WorkError( - ResultStatus.UNKNOWN, - appInstall - ) - ) - ) - } - - private suspend fun updateFusedDownloadWithAppDownloadLink( - appInstall: AppInstall - ) { - applicationRepository.updateFusedDownloadWithDownloadingInfo( - appInstall.source, - appInstall - ) + return appInstallStartCoordinator.canEnqueue(appInstall) } - @OptIn(DelicateCoroutinesApi::class) suspend fun processInstall( fusedDownloadId: String, isItUpdateWork: Boolean, diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt new file mode 100644 index 000000000..5b11557ef --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt @@ -0,0 +1,179 @@ +/* + * 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.content.Context +import com.aurora.gplayapi.exceptions.InternalException +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManager +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import foundation.e.apps.data.preference.PlayStoreAuthStore +import foundation.e.apps.domain.model.User +import foundation.e.apps.domain.preferences.SessionRepository +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList", "TooGenericExceptionCaught", "ReturnCount") // FIXME: Remove suppression and fix detekt +class AppInstallStartCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + private val appManagerWrapper: AppManagerWrapper, + private val applicationRepository: ApplicationRepository, + private val sessionRepository: SessionRepository, + private val playStoreAuthStore: PlayStoreAuthStore, + private val storageNotificationManager: StorageNotificationManager, + private val appInstallAgeLimitGate: AppInstallAgeLimitGate, + private val appEventDispatcher: AppEventDispatcher, + private val storageSpaceChecker: StorageSpaceChecker, + private val networkStatusChecker: NetworkStatusChecker, + private val appManager: AppManager, +) { + suspend fun enqueue( + appInstall: AppInstall, + isAnUpdate: Boolean = false, + isSystemApp: Boolean = false + ): Boolean { + return try { + val user = sessionRepository.awaitUser() + if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { + val authData = playStoreAuthStore.awaitAuthData() + if (!appInstall.isFree && authData?.isAnonymous == true) { + appEventDispatcher.dispatch( + AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message) + ) + } + } + + if (!canEnqueue(appInstall)) return false + + appManagerWrapper.updateAwaiting(appInstall) + InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate) + true + } catch (e: Exception) { + Timber.e( + e, + "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}" + ) + appManagerWrapper.installationIssue(appInstall) + false + } + } + + suspend fun canEnqueue(appInstall: AppInstall): Boolean { + if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) { + return false + } + + if (!appManagerWrapper.addDownload(appInstall)) { + Timber.i("Update adding ABORTED! status") + return false + } + + if (!appInstallAgeLimitGate.allow(appInstall)) { + return false + } + + if (!networkStatusChecker.isNetworkAvailable()) { + appManagerWrapper.installationIssue(appInstall) + appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false)) + return false + } + + if (storageSpaceChecker.spaceMissing(appInstall) > 0) { + Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") + storageNotificationManager.showNotEnoughSpaceNotification(appInstall) + appManagerWrapper.installationIssue(appInstall) + appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) + return false + } + + return true + } + + private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean { + try { + updateFusedDownloadWithAppDownloadLink(appInstall) + } catch (_: InternalException.AppNotPurchased) { + if (appInstall.isFree) { + handleAppRestricted(appInstall) + return false + } + appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) + appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) + return false + } catch (e: GplayHttpRequestException) { + handleUpdateDownloadError( + appInstall, + "${appInstall.packageName} code: ${e.status} exception: ${e.localizedMessage}", + e + ) + return false + } catch (e: IllegalStateException) { + Timber.e(e) + } catch (e: Exception) { + handleUpdateDownloadError( + appInstall, + "${appInstall.packageName} exception: ${e.localizedMessage}", + e + ) + return false + } + return true + } + + private suspend fun handleAppRestricted(appInstall: AppInstall) { + appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) + appManager.addDownload(appInstall) + appManager.updateUnavailable(appInstall) + } + + private suspend fun handleUpdateDownloadError( + appInstall: AppInstall, + message: String, + e: Exception + ) { + Timber.e(e, "Updating download Urls failed for $message") + appEventDispatcher.dispatch( + AppEvent.UpdateEvent( + ResultSupreme.WorkError( + ResultStatus.UNKNOWN, + appInstall + ) + ) + ) + } + + private suspend fun updateFusedDownloadWithAppDownloadLink(appInstall: AppInstall) { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + appInstall.source, + appInstall + ) + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index ced8405d1..dfa41e1c0 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -36,8 +36,9 @@ import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate -import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory +import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler import foundation.e.apps.data.install.workmanager.InstallWorkManager @@ -118,6 +119,7 @@ class AppInstallProcessorTest { private lateinit var updatesNotificationSender: UpdatesNotificationSender private lateinit var networkStatusChecker: NetworkStatusChecker private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate + private lateinit var appInstallStartCoordinator: AppInstallStartCoordinator private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler private lateinit var appInstallWorkRunner: AppInstallWorkRunner private lateinit var appInstallRequestFactory: AppInstallRequestFactory @@ -150,6 +152,19 @@ class AppInstallProcessorTest { appEventDispatcher, parentalControlAuthGateway ) + appInstallStartCoordinator = AppInstallStartCoordinator( + context, + fakeFusedManagerRepository, + applicationRepository, + sessionRepository, + playStoreAuthStore, + storageNotificationManager, + appInstallAgeLimitGate, + appEventDispatcher, + storageSpaceChecker, + networkStatusChecker, + fakeFusedManager + ) appUpdateCompletionHandler = AppUpdateCompletionHandler( context, appInstallRepository, @@ -168,16 +183,8 @@ class AppInstallProcessorTest { ) appInstallProcessor = AppInstallProcessor( - context, appInstallComponents, - applicationRepository, - sessionRepository, - playStoreAuthStore, - storageNotificationManager, - appEventDispatcher, - storageSpaceChecker, - networkStatusChecker, - appInstallAgeLimitGate, + appInstallStartCoordinator, appInstallWorkRunner, appInstallRequestFactory ) @@ -653,23 +660,28 @@ class AppInstallProcessorTest { appEventDispatcher, parentalControlAuthGateway ) - val workRunner = AppInstallWorkRunner( - appInstallRepository, - appManagerWrapper, - mockk(relaxed = true), - appUpdateCompletionHandler - ) - return AppInstallProcessor( + val startCoordinator = AppInstallStartCoordinator( context, - appInstallComponents, + appManagerWrapper, applicationRepository, sessionRepository, playStoreAuthStore, storageNotificationManager, + ageLimitGate, appEventDispatcher, storageSpaceChecker, networkStatusChecker, - ageLimitGate, + fakeFusedManager + ) + val workRunner = AppInstallWorkRunner( + appInstallRepository, + appManagerWrapper, + mockk(relaxed = true), + appUpdateCompletionHandler + ) + return AppInstallProcessor( + appInstallComponents, + startCoordinator, workRunner, appInstallRequestFactory ) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt new file mode 100644 index 000000000..944a60f8c --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt @@ -0,0 +1,282 @@ +/* + * 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.installProcessor + +import android.content.Context +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.exceptions.InternalException +import foundation.e.apps.R +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManager +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate +import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator +import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import foundation.e.apps.data.preference.PlayStoreAuthStore +import foundation.e.apps.domain.model.User +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.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AppInstallStartCoordinatorTest { + private lateinit var context: Context + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var applicationRepository: ApplicationRepository + private lateinit var sessionRepository: SessionRepository + private lateinit var playStoreAuthStore: PlayStoreAuthStore + private lateinit var storageNotificationManager: StorageNotificationManager + private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var storageSpaceChecker: StorageSpaceChecker + private lateinit var networkStatusChecker: NetworkStatusChecker + private lateinit var appManager: AppManager + private lateinit var coordinator: AppInstallStartCoordinator + + @Before + fun setup() { + context = mockk(relaxed = true) + appManagerWrapper = mockk(relaxed = true) + applicationRepository = mockk(relaxed = true) + sessionRepository = mockk(relaxed = true) + playStoreAuthStore = mockk(relaxed = true) + storageNotificationManager = mockk(relaxed = true) + appInstallAgeLimitGate = mockk(relaxed = true) + appEventDispatcher = FakeAppEventDispatcher() + storageSpaceChecker = mockk(relaxed = true) + networkStatusChecker = mockk(relaxed = true) + appManager = mockk(relaxed = true) + coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE + coEvery { playStoreAuthStore.awaitAuthData() } returns null + coordinator = AppInstallStartCoordinator( + context, + appManagerWrapper, + applicationRepository, + sessionRepository, + playStoreAuthStore, + storageNotificationManager, + appInstallAgeLimitGate, + appEventDispatcher, + storageSpaceChecker, + networkStatusChecker, + appManager + ) + } + + @Test + fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = coordinator.canEnqueue(appInstall) + + assertTrue(result) + } + + @Test + fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns false + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + coVerify { appManagerWrapper.installationIssue(appInstall) } + } + + @Test + fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + verify { storageNotificationManager.showNotEnoughSpaceNotification(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + } + + @Test + fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns false + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + coVerify(exactly = 0) { appInstallAgeLimitGate.allow(any()) } + } + + @Test + fun enqueue_warnsAnonymousPaidUsersWithoutAborting() = runTest { + val appInstall = createPwaInstall(isFree = false) + + mockkObject(InstallWorkManager) + try { + coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS + coEvery { + playStoreAuthStore.awaitAuthData() + } returns AuthData(email = "anon@example.com", isAnonymous = true) + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } + + val result = coordinator.enqueue(appInstall) + + assertTrue(result) + assertTrue(appEventDispatcher.events.any { + it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message + }) + coVerify { appManagerWrapper.updateAwaiting(appInstall) } + verify { InstallWorkManager.enqueueWork(context, appInstall, false) } + } finally { + unmockkObject(InstallWorkManager) + } + } + + @Test + fun canEnqueue_handlesFreeAppNotPurchasedAsRestricted() = runTest { + val appInstall = createNativeInstall(isFree = true) + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws InternalException.AppNotPurchased() + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.AppRestrictedOrUnavailable }) + coVerify { appManager.addDownload(appInstall) } + coVerify { appManager.updateUnavailable(appInstall) } + } + + @Test + fun canEnqueue_handlesPaidAppNotPurchasedAsPurchaseNeeded() = runTest { + val appInstall = createNativeInstall(isFree = false) + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws InternalException.AppNotPurchased() + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + coVerify { appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) } + assertTrue(appEventDispatcher.events.any { it is AppEvent.AppPurchaseEvent }) + } + + @Test + fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsHttpError() = runTest { + val appInstall = createNativeInstall() + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws GplayHttpRequestException(403, "forbidden") + + val result = coordinator.canEnqueue(appInstall) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent }) + coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun canEnqueue_keepsGoingWhenDownloadUrlRefreshThrowsIllegalState() = runTest { + val appInstall = createNativeInstall() + + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws IllegalStateException("boom") + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = coordinator.canEnqueue(appInstall) + + assertTrue(result) + coVerify { appManagerWrapper.addDownload(appInstall) } + } + + private fun createPwaInstall(isFree: Boolean = true) = AppInstall( + type = Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app", + isFree = isFree + ) + + private fun createNativeInstall(isFree: Boolean = true) = AppInstall( + type = Type.NATIVE, + source = Source.PLAY_STORE, + id = "123", + status = Status.AWAITING, + packageName = "com.example.app", + isFree = isFree + ) +} -- GitLab From c7f9990ab2c3bc1a5a2000e7362507c8cd6bb5c6 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 13 Mar 2026 00:10:36 +0600 Subject: [PATCH 09/10] refactor: make AppInstallProcessor to facade behavior Keep the processor focused on request-to-collaborator delegation so its tests describe the public contract while the extracted components own the detailed behavior coverage. --- .../workmanager/AppInstallProcessor.kt | 6 - .../AppInstallProcessorTest.kt | 642 ++---------------- 2 files changed, 42 insertions(+), 606 deletions(-) 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 2e8f21bf4..4f0ecffcb 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 @@ -18,7 +18,6 @@ package foundation.e.apps.data.install.workmanager -import androidx.annotation.VisibleForTesting import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Status @@ -67,11 +66,6 @@ class AppInstallProcessor @Inject constructor( return appInstallStartCoordinator.enqueue(appInstall, isAnUpdate, isSystemApp) } - @VisibleForTesting - suspend fun canEnqueue(appInstall: AppInstall): Boolean { - return appInstallStartCoordinator.canEnqueue(appInstall) - } - suspend fun processInstall( fusedDownloadId: String, isItUpdateWork: Boolean, diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt index dfa41e1c0..72384b6ca 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright MURENA SAS 2023 + * Copyright MURENA SAS 2026 * Apps Quickly and easily install Android apps onto your device! * * This program is free software: you can redistribute it and/or modify @@ -18,50 +18,24 @@ package foundation.e.apps.installProcessor -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.aurora.gplayapi.data.models.AuthData -import foundation.e.apps.R -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status -import foundation.e.apps.data.event.AppEvent -import foundation.e.apps.data.fdroid.FDroidRepository +import foundation.e.apps.data.enums.Type import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.AppInstallRepository -import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall -import foundation.e.apps.data.install.notification.StorageNotificationManager -import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner -import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler -import foundation.e.apps.data.install.workmanager.InstallWorkManager -import foundation.e.apps.data.install.wrapper.NetworkStatusChecker -import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway -import foundation.e.apps.data.install.wrapper.StorageSpaceChecker -import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender -import foundation.e.apps.data.install.wrapper.UpdatesTracker -import foundation.e.apps.data.playstore.utils.GplayHttpRequestException -import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.domain.ValidateAppAgeLimitUseCase -import foundation.e.apps.domain.model.ContentRatingValidity -import foundation.e.apps.domain.model.User -import foundation.e.apps.domain.preferences.SessionRepository import foundation.e.apps.util.MainCoroutineRule 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.unmockkObject -import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -69,118 +43,30 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mock -import org.mockito.Mockito -import org.mockito.MockitoAnnotations -import java.util.Locale @OptIn(ExperimentalCoroutinesApi::class) class AppInstallProcessorTest { - // Run tasks synchronously @Rule @JvmField val instantExecutorRule = InstantTaskExecutorRule() - // Sets the main coroutines dispatcher to a TestCoroutineScope for unit testing. - @ExperimentalCoroutinesApi @get:Rule var mainCoroutineRule = MainCoroutineRule() - private lateinit var fakeFusedDownloadDAO: FakeAppInstallDAO - private lateinit var appInstallRepository: AppInstallRepository - private lateinit var fakeFusedManagerRepository: FakeAppManagerWrapper - - @Mock - private lateinit var fakeFusedManager: AppManager - - @Mock - private lateinit var fakeFDroidRepository: FDroidRepository - - private lateinit var context: Context - - private lateinit var sessionRepository: SessionRepository - - private lateinit var playStoreAuthStore: PlayStoreAuthStore - - private lateinit var applicationRepository: ApplicationRepository - + private lateinit var appManagerWrapper: AppManagerWrapper private lateinit var appInstallProcessor: AppInstallProcessor - - @Mock - private lateinit var validateAppAgeRatingUseCase: ValidateAppAgeLimitUseCase - - @Mock - private lateinit var storageNotificationManager: StorageNotificationManager - - private lateinit var appEventDispatcher: FakeAppEventDispatcher - private lateinit var storageSpaceChecker: StorageSpaceChecker - private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway - private lateinit var updatesTracker: UpdatesTracker - private lateinit var updatesNotificationSender: UpdatesNotificationSender - private lateinit var networkStatusChecker: NetworkStatusChecker - private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate + private lateinit var appInstallRequestFactory: AppInstallRequestFactory private lateinit var appInstallStartCoordinator: AppInstallStartCoordinator - private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler private lateinit var appInstallWorkRunner: AppInstallWorkRunner - private lateinit var appInstallRequestFactory: AppInstallRequestFactory @Before fun setup() { - MockitoAnnotations.openMocks(this) - context = mockk(relaxed = true) - sessionRepository = mockk(relaxed = true) - playStoreAuthStore = mockk(relaxed = true) - applicationRepository = mockk(relaxed = true) - coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE - coEvery { playStoreAuthStore.awaitAuthData() } returns null - appEventDispatcher = FakeAppEventDispatcher() - appInstallRequestFactory = AppInstallRequestFactory() - storageSpaceChecker = mockk(relaxed = true) - parentalControlAuthGateway = mockk(relaxed = true) - updatesTracker = mockk(relaxed = true) - updatesNotificationSender = mockk(relaxed = true) - networkStatusChecker = mockk(relaxed = true) - fakeFusedDownloadDAO = FakeAppInstallDAO() - appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO) - fakeFusedManagerRepository = - FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository) - val appInstallComponents = - AppInstallComponents(appInstallRepository, fakeFusedManagerRepository) - appInstallAgeLimitGate = AppInstallAgeLimitGate( - validateAppAgeRatingUseCase, - fakeFusedManagerRepository, - appEventDispatcher, - parentalControlAuthGateway - ) - appInstallStartCoordinator = AppInstallStartCoordinator( - context, - fakeFusedManagerRepository, - applicationRepository, - sessionRepository, - playStoreAuthStore, - storageNotificationManager, - appInstallAgeLimitGate, - appEventDispatcher, - storageSpaceChecker, - networkStatusChecker, - fakeFusedManager - ) - appUpdateCompletionHandler = AppUpdateCompletionHandler( - context, - appInstallRepository, - fakeFusedManagerRepository, - playStoreAuthStore, - updatesTracker, - updatesNotificationSender - ) - val downloadManager = - mockk(relaxed = true) - appInstallWorkRunner = AppInstallWorkRunner( - appInstallRepository, - fakeFusedManagerRepository, - downloadManager, - appUpdateCompletionHandler - ) + appManagerWrapper = mockk(relaxed = true) + val appInstallRepository = mockk(relaxed = true) + val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) + appInstallRequestFactory = mockk(relaxed = true) + appInstallStartCoordinator = mockk(relaxed = true) + appInstallWorkRunner = mockk(relaxed = true) appInstallProcessor = AppInstallProcessor( appInstallComponents, @@ -191,499 +77,55 @@ class AppInstallProcessorTest { } @Test - fun processInstallTest() = runTest { - val fusedDownload = initTest() - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertTrue("processInstall", finalFusedDownload == null) - } - - private suspend fun initTest( - packageName: String? = null, - downloadUrlList: MutableList? = null - ): AppInstall { - val fusedDownload = createFusedDownload(packageName, downloadUrlList) - fakeFusedDownloadDAO.addDownload(fusedDownload) - return fusedDownload - } - - @Test - fun `processInstallTest when FusedDownload is already failed`() = runTest { - val fusedDownload = initTest() - fusedDownload.status = Status.BLOCKED - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.BLOCKED, finalFusedDownload?.status) - } - - @Test - fun `processInstallTest when files are downloaded but not installed`() = runTest { - val fusedDownload = initTest() - fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true)) - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertTrue("processInstall", finalFusedDownload == null) - } - - @Test - fun `processInstallTest when packageName is empty and files are downloaded`() = runTest { - val fusedDownload = initTest(packageName = "") - fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true)) - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) - } - - @Test - fun `processInstallTest when downloadUrls are not available`() = runTest { - val fusedDownload = initTest(downloadUrlList = mutableListOf()) - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) - } - - @Test - fun `processInstallTest when exception is occurred`() = runTest { - val fusedDownload = initTest() - fakeFusedManagerRepository.forceCrash = true - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertTrue( - "processInstall", - finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE - ) - } - - @Test - fun `processInstallTest when download is failed`() = runTest { - val fusedDownload = initTest() - fakeFusedManagerRepository.willDownloadFail = true - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) - } - - @Test - fun `processInstallTest when install is failed`() = runTest { - val fusedDownload = initTest() - fakeFusedManagerRepository.willInstallFail = true - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status) - } - - @Test - fun `processInstallTest when age limit is satisfied`() = runTest { - val fusedDownload = initTest() - Mockito.`when`(validateAppAgeRatingUseCase(fusedDownload)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", finalFusedDownload, null) - } - - @Test - fun `processInstallTest when age limit is not satisfied`() = runTest { - val fusedDownload = initTest() - Mockito.`when`(validateAppAgeRatingUseCase(fusedDownload)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false))) - - val finalFusedDownload = runProcessInstall(fusedDownload) - assertEquals("processInstall", finalFusedDownload, null) - } - - @Test - fun `enqueueFusedDownload warns anonymous paid users without aborting`() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.paid", - isFree = false - ) - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockkObject(InstallWorkManager) - try { - coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS - coEvery { - playStoreAuthStore.awaitAuthData() - } returns AuthData(email = "anon@example.com", isAnonymous = true) - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L - justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } - - val result = processor.enqueueFusedDownload(appInstall) - - assertTrue(result) - assertTrue(appEventDispatcher.events.any { - it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message - }) - coVerify { appManagerWrapper.updateAwaiting(appInstall) } - verify { InstallWorkManager.enqueueWork(context, appInstall, false) } - } finally { - unmockkObject(InstallWorkManager) - } - } - - @Test - fun `canEnqueue refreshes download urls for non-PWA installs`() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.NATIVE, + fun initAppInstall_computesUpdateFlagAndDelegates() = runTest { + val application = Application( + _id = "123", source = Source.PLAY_STORE, - id = "123", - status = Status.AWAITING, - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + status = Status.UPDATABLE, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { appInstallRequestFactory.create(application) } returns appInstall + coEvery { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false + coEvery { + appInstallStartCoordinator.enqueue( + appInstall, + true, + application.isSystemApp + ) + } returns true - val result = processor.canEnqueue(appInstall) + val result = appInstallProcessor.initAppInstall(application) assertTrue(result) - coVerify { applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) } - coVerify { appManagerWrapper.addDownload(appInstall) } + coVerify { appInstallRequestFactory.create(application) } + coVerify { appInstallStartCoordinator.enqueue(appInstall, true, application.isSystemApp) } } @Test - fun `canEnqueue returns false when download url refresh throws http error`() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.NATIVE, - source = Source.PLAY_STORE, - id = "123", - status = Status.AWAITING, - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) + fun enqueueFusedDownload_delegatesResult() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { appInstallStartCoordinator.enqueue(appInstall, true, true) } returns false - coEvery { - applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) - } throws GplayHttpRequestException(403, "forbidden") - - val result = processor.canEnqueue(appInstall) + val result = appInstallProcessor.enqueueFusedDownload(appInstall, true, true) assertEquals(false, result) - assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent }) - coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } + coVerify { appInstallStartCoordinator.enqueue(appInstall, true, true) } } @Test - fun `canEnqueue keeps going when download url refresh throws illegal state`() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.NATIVE, - source = Source.PLAY_STORE, - id = "123", - status = Status.AWAITING, - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - + fun processInstall_delegatesResult() = runTest { coEvery { - applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) - } throws IllegalStateException("boom") - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + appInstallWorkRunner.processInstall("123", false, any()) + } returns Result.success(ResultStatus.OK) - val result = processor.canEnqueue(appInstall) - - assertTrue(result) - coVerify { appManagerWrapper.addDownload(appInstall) } - } - - @Test - fun `processInstall returns success when internal exception occurs`() = runTest { - val fusedDownload = initTest() - fakeFusedManagerRepository.forceCrash = true - - val result = appInstallProcessor.processInstall(fusedDownload.id, false) { + val result = appInstallProcessor.processInstall("123", false) { // _ignored_ } - assertTrue(result.isSuccess) assertEquals(ResultStatus.OK, result.getOrNull()) - } - - @Test - fun `processInstall enters foreground before download starts`() = runTest { - val fusedDownload = initTest() - var statusAtForeground: Status? = null - - appInstallProcessor.processInstall(fusedDownload.id, false) { - statusAtForeground = fusedDownload.status - } - - assertEquals(Status.AWAITING, statusAtForeground) - } - - @Test - fun `processInstall update completion ignores installation issues`() = runTest { - stubUpdateNotificationContext() - every { updatesTracker.hasSuccessfulUpdatedApps() } returns true - every { updatesTracker.successfulUpdatedAppsCount() } returns 1 - - processCompletedUpdate(Status.INSTALLATION_ISSUE) - - verify { updatesTracker.addSuccessfullyUpdatedApp(any()) } - verify { updatesNotificationSender.showNotification("Update", "Updated message") } - verify { updatesTracker.clearSuccessfullyUpdatedApps() } - } - - @Test - fun `processInstall update completion ignores purchase needed`() = runTest { - stubUpdateNotificationContext() - every { updatesTracker.hasSuccessfulUpdatedApps() } returns true - every { updatesTracker.successfulUpdatedAppsCount() } returns 1 - - processCompletedUpdate(Status.PURCHASE_NEEDED) - - verify { updatesTracker.addSuccessfullyUpdatedApp(any()) } - verify { updatesNotificationSender.showNotification("Update", "Updated message") } - verify { updatesTracker.clearSuccessfullyUpdatedApps() } - } - - @Test - fun `processInstall clears tracked updates after final notification`() = runTest { - stubUpdateNotificationContext() - every { updatesTracker.hasSuccessfulUpdatedApps() } returns true - every { updatesTracker.successfulUpdatedAppsCount() } returns 1 - - processCompletedUpdate() - - verify { updatesTracker.clearSuccessfullyUpdatedApps() } - } - - @Test - fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0 - - val result = processor.canEnqueue(appInstall) - - assertTrue(result) - coVerify { appManagerWrapper.addDownload(appInstall) } - } - - @Test - fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns false - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0 - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify { appManagerWrapper.installationIssue(appInstall) } - } - - @Test - fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall) - coVerify { appManagerWrapper.installationIssue(appInstall) } - } - - @Test - fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns false - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } - } - - @Test - fun canEnqueue_returnsFalseWhenAgeLimitInvalid() = runTest { - val appInstall = AppInstall( - type = foundation.e.apps.data.enums.Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false))) - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify { appManagerWrapper.cancelDownload(appInstall) } - } - - private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { - appInstallProcessor.processInstall(appInstall.id, false) { - // _ignored_ - } - return fakeFusedDownloadDAO.getDownloadById(appInstall.id) - } - - private suspend fun processCompletedUpdate(ignoredStatus: Status? = null) { - val fusedDownload = initTest() - fakeFusedManagerRepository.isAppInstalled = true - - ignoredStatus?.let { - fakeFusedDownloadDAO.addDownload( - AppInstall( - id = "ignored-$it", - status = it, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.$it" - ) - ) - } - - appInstallProcessor.processInstall(fusedDownload.id, true) { - // _ignored_ - } - } - - private suspend fun stubUpdateNotificationContext() { - val authData = AuthData(email = "user@example.com", isAnonymous = false).apply { - locale = Locale.US - } - coEvery { playStoreAuthStore.awaitAuthData() } returns authData - every { context.getString(R.string.update) } returns "Update" - every { - context.getString( - R.string.message_last_update_triggered, - any(), - any() - ) - } returns "Updated message" - } - - private fun createFusedDownload( - packageName: String? = null, - downloadUrlList: MutableList? = null - ) = AppInstall( - id = "121", - status = Status.AWAITING, - downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"), - packageName = packageName ?: "com.unit.test" - ) - - private fun createProcessorForCanEnqueue( - appManagerWrapper: AppManagerWrapper - ): AppInstallProcessor { - val appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) - val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) - val ageLimitGate = AppInstallAgeLimitGate( - validateAppAgeRatingUseCase, - appManagerWrapper, - appEventDispatcher, - parentalControlAuthGateway - ) - val startCoordinator = AppInstallStartCoordinator( - context, - appManagerWrapper, - applicationRepository, - sessionRepository, - playStoreAuthStore, - storageNotificationManager, - ageLimitGate, - appEventDispatcher, - storageSpaceChecker, - networkStatusChecker, - fakeFusedManager - ) - val workRunner = AppInstallWorkRunner( - appInstallRepository, - appManagerWrapper, - mockk(relaxed = true), - appUpdateCompletionHandler - ) - return AppInstallProcessor( - appInstallComponents, - startCoordinator, - workRunner, - appInstallRequestFactory - ) + coVerify { appInstallWorkRunner.processInstall("123", false, any()) } } } -- GitLab From dfe660288a17eeab95878509dbf0b4a1cca5f381 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Fri, 13 Mar 2026 01:43:58 +0600 Subject: [PATCH 10/10] refactor: resolve detekt complaints --- .../workmanager/AppInstallAgeLimitGate.kt | 42 +++-- .../AppInstallDevicePreconditions.kt | 63 +++++++ .../AppInstallDownloadUrlRefresher.kt | 111 +++++++++++++ .../AppInstallPreEnqueueChecker.kt | 51 ++++++ .../workmanager/AppInstallProcessor.kt | 1 - .../workmanager/AppInstallStartCoordinator.kt | 154 ++++-------------- .../workmanager/AppInstallWorkRunner.kt | 62 ++++--- .../AppInstallStartCoordinatorTest.kt | 44 +++-- 8 files changed, 352 insertions(+), 176 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt create mode 100644 app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt index 56cabf5fe..c5962bc48 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt @@ -29,7 +29,6 @@ import foundation.e.apps.domain.model.ContentRatingValidity import kotlinx.coroutines.CompletableDeferred import javax.inject.Inject -@Suppress("ReturnCount") // FIXME: Remove suppression and fix detekt class AppInstallAgeLimitGate @Inject constructor( private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase, private val appManagerWrapper: AppManagerWrapper, @@ -38,28 +37,37 @@ class AppInstallAgeLimitGate @Inject constructor( ) { suspend fun allow(appInstall: AppInstall): Boolean { val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall) - if (ageLimitValidationResult.data?.isValid == true) { - return true - } + val isAllowed = when { + ageLimitValidationResult.data?.isValid == true -> true + ageLimitValidationResult.isSuccess() -> handleSuccessfulValidation( + ageLimitValidationResult, + appInstall.name + ) - if (ageLimitValidationResult.isSuccess()) { - awaitInvokeAgeLimitEvent(appInstall.name) - if (ageLimitValidationResult.data?.requestPin == true) { - val isAuthenticated = parentalControlAuthGateway.awaitAuthentication() - if (isAuthenticated) { - ageLimitValidationResult.setData(ContentRatingValidity(true)) - } + else -> { + appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) + false } - } else { - appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) } - if (ageLimitValidationResult.data?.isValid == true) { - return true + if (!isAllowed) { + appManagerWrapper.cancelDownload(appInstall) } - appManagerWrapper.cancelDownload(appInstall) - return false + return isAllowed + } + + private suspend fun handleSuccessfulValidation( + ageLimitValidationResult: foundation.e.apps.data.ResultSupreme, + appName: String + ): Boolean { + awaitInvokeAgeLimitEvent(appName) + if (ageLimitValidationResult.data?.requestPin == true && + parentalControlAuthGateway.awaitAuthentication() + ) { + ageLimitValidationResult.setData(ContentRatingValidity(true)) + } + return ageLimitValidationResult.data?.isValid == true } private suspend fun awaitInvokeAgeLimitEvent(type: String) { diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt new file mode 100644 index 000000000..e43499ad4 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt @@ -0,0 +1,63 @@ +/* + * 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 foundation.e.apps.R +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.notification.StorageNotificationManager +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import timber.log.Timber +import javax.inject.Inject + +class AppInstallDevicePreconditions @Inject constructor( + private val appManagerWrapper: AppManagerWrapper, + private val appEventDispatcher: AppEventDispatcher, + private val storageNotificationManager: StorageNotificationManager, + private val storageSpaceChecker: StorageSpaceChecker, + private val networkStatusChecker: NetworkStatusChecker, +) { + suspend fun canProceed(appInstall: AppInstall): Boolean { + val hasNetwork = hasNetworkConnection(appInstall) + return hasNetwork && hasStorageSpace(appInstall) + } + + private suspend fun hasNetworkConnection(appInstall: AppInstall): Boolean { + val hasNetwork = networkStatusChecker.isNetworkAvailable() + if (!hasNetwork) { + appManagerWrapper.installationIssue(appInstall) + appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false)) + } + return hasNetwork + } + + private suspend fun hasStorageSpace(appInstall: AppInstall): Boolean { + val missingStorage = storageSpaceChecker.spaceMissing(appInstall) + if (missingStorage > 0) { + Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") + storageNotificationManager.showNotEnoughSpaceNotification(appInstall) + appManagerWrapper.installationIssue(appInstall) + appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) + } + return missingStorage <= 0 + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt new file mode 100644 index 000000000..281eb4022 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt @@ -0,0 +1,111 @@ +/* + * 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 com.aurora.gplayapi.exceptions.InternalException +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManager +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import kotlinx.coroutines.CancellationException +import timber.log.Timber +import javax.inject.Inject + +class AppInstallDownloadUrlRefresher @Inject constructor( + private val applicationRepository: ApplicationRepository, + private val appManagerWrapper: AppManagerWrapper, + private val appEventDispatcher: AppEventDispatcher, + private val appManager: AppManager, +) { + suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean { + return runCatching { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + appInstall.source, + appInstall + ) + }.fold( + onSuccess = { true }, + onFailure = { throwable -> handleUpdateDownloadFailure(appInstall, throwable) } + ) + } + + private suspend fun handleUpdateDownloadFailure(appInstall: AppInstall, throwable: Throwable): Boolean { + return when (throwable) { + is CancellationException -> throw throwable + is InternalException.AppNotPurchased -> handleAppNotPurchased(appInstall) + is GplayHttpRequestException -> { + handleUpdateDownloadError( + appInstall, + "${appInstall.packageName} code: ${throwable.status} exception: ${throwable.localizedMessage}", + throwable + ) + false + } + + is IllegalStateException -> { + Timber.e(throwable) + false + } + + is Exception -> { + handleUpdateDownloadError( + appInstall, + "${appInstall.packageName} exception: ${throwable.localizedMessage}", + throwable + ) + false + } + + else -> throw throwable + } + } + + private suspend fun handleAppNotPurchased(appInstall: AppInstall): Boolean { + if (appInstall.isFree) { + appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) + appManager.addDownload(appInstall) + appManager.updateUnavailable(appInstall) + } else { + appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) + appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) + } + return false + } + + private suspend fun handleUpdateDownloadError( + appInstall: AppInstall, + message: String, + exception: Exception + ) { + Timber.e(exception, "Updating download Urls failed for $message") + appEventDispatcher.dispatch( + AppEvent.UpdateEvent( + ResultSupreme.WorkError( + ResultStatus.UNKNOWN, + appInstall + ) + ) + ) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt new file mode 100644 index 000000000..e1b380350 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt @@ -0,0 +1,51 @@ +/* + * 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 foundation.e.apps.data.enums.Type +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import timber.log.Timber +import javax.inject.Inject + +class AppInstallPreEnqueueChecker @Inject constructor( + private val appInstallDownloadUrlRefresher: AppInstallDownloadUrlRefresher, + private val appManagerWrapper: AppManagerWrapper, + private val appInstallAgeLimitGate: AppInstallAgeLimitGate, + private val appInstallDevicePreconditions: AppInstallDevicePreconditions, +) { + suspend fun canEnqueue(appInstall: AppInstall): Boolean { + val hasUpdatedDownloadUrls = appInstall.type == Type.PWA || + appInstallDownloadUrlRefresher.updateDownloadUrls(appInstall) + + val isDownloadAdded = hasUpdatedDownloadUrls && addDownload(appInstall) + val isAgeLimitAllowed = isDownloadAdded && appInstallAgeLimitGate.allow(appInstall) + + return isAgeLimitAllowed && appInstallDevicePreconditions.canProceed(appInstall) + } + + private suspend fun addDownload(appInstall: AppInstall): Boolean { + val isDownloadAdded = appManagerWrapper.addDownload(appInstall) + if (!isDownloadAdded) { + Timber.i("Update adding ABORTED! status") + } + + return isDownloadAdded + } +} 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 4f0ecffcb..20a7d7b97 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 @@ -25,7 +25,6 @@ import foundation.e.apps.data.install.AppInstallComponents import foundation.e.apps.data.install.models.AppInstall import javax.inject.Inject -@Suppress("LongParameterList") // FIXME: Remove suppression and fix detekt class AppInstallProcessor @Inject constructor( private val appInstallComponents: AppInstallComponents, private val appInstallStartCoordinator: AppInstallStartCoordinator, diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt index 5b11557ef..a88a1b967 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt @@ -19,161 +19,73 @@ package foundation.e.apps.data.install.workmanager import android.content.Context -import com.aurora.gplayapi.exceptions.InternalException import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.enums.ResultStatus -import foundation.e.apps.data.enums.Type import foundation.e.apps.data.event.AppEvent -import foundation.e.apps.data.install.AppManager import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall -import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.wrapper.AppEventDispatcher -import foundation.e.apps.data.install.wrapper.NetworkStatusChecker -import foundation.e.apps.data.install.wrapper.StorageSpaceChecker -import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.domain.model.User import foundation.e.apps.domain.preferences.SessionRepository +import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject -@Suppress("LongParameterList", "TooGenericExceptionCaught", "ReturnCount") // FIXME: Remove suppression and fix detekt class AppInstallStartCoordinator @Inject constructor( @ApplicationContext private val context: Context, + private val appInstallPreEnqueueChecker: AppInstallPreEnqueueChecker, private val appManagerWrapper: AppManagerWrapper, - private val applicationRepository: ApplicationRepository, private val sessionRepository: SessionRepository, private val playStoreAuthStore: PlayStoreAuthStore, - private val storageNotificationManager: StorageNotificationManager, - private val appInstallAgeLimitGate: AppInstallAgeLimitGate, private val appEventDispatcher: AppEventDispatcher, - private val storageSpaceChecker: StorageSpaceChecker, - private val networkStatusChecker: NetworkStatusChecker, - private val appManager: AppManager, ) { suspend fun enqueue( appInstall: AppInstall, isAnUpdate: Boolean = false, isSystemApp: Boolean = false ): Boolean { - return try { - val user = sessionRepository.awaitUser() - if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { - val authData = playStoreAuthStore.awaitAuthData() - if (!appInstall.isFree && authData?.isAnonymous == true) { - appEventDispatcher.dispatch( - AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message) - ) - } + return runCatching { + dispatchAnonymousPaidAppWarning(appInstall, isSystemApp) + + val canEnqueue = canEnqueue(appInstall) + if (canEnqueue) { + appManagerWrapper.updateAwaiting(appInstall) + InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate) } - if (!canEnqueue(appInstall)) return false + canEnqueue + }.getOrElse { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "Enqueuing App install work is failed for ${appInstall.packageName} " + + "exception: ${throwable.localizedMessage}" + ) + appManagerWrapper.installationIssue(appInstall) + false + } - appManagerWrapper.updateAwaiting(appInstall) - InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate) - true - } catch (e: Exception) { - Timber.e( - e, - "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}" - ) - appManagerWrapper.installationIssue(appInstall) - false + else -> throw throwable + } } } suspend fun canEnqueue(appInstall: AppInstall): Boolean { - if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) { - return false - } - - if (!appManagerWrapper.addDownload(appInstall)) { - Timber.i("Update adding ABORTED! status") - return false - } - - if (!appInstallAgeLimitGate.allow(appInstall)) { - return false - } - - if (!networkStatusChecker.isNetworkAvailable()) { - appManagerWrapper.installationIssue(appInstall) - appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false)) - return false - } - - if (storageSpaceChecker.spaceMissing(appInstall) > 0) { - Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}") - storageNotificationManager.showNotEnoughSpaceNotification(appInstall) - appManagerWrapper.installationIssue(appInstall) - appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage)) - return false - } - - return true + return appInstallPreEnqueueChecker.canEnqueue(appInstall) } - private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean { - try { - updateFusedDownloadWithAppDownloadLink(appInstall) - } catch (_: InternalException.AppNotPurchased) { - if (appInstall.isFree) { - handleAppRestricted(appInstall) - return false + private suspend fun dispatchAnonymousPaidAppWarning(appInstall: AppInstall, isSystemApp: Boolean) { + val user = sessionRepository.awaitUser() + if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { + val authData = playStoreAuthStore.awaitAuthData() + if (!appInstall.isFree && authData?.isAnonymous == true) { + appEventDispatcher.dispatch( + AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message) + ) } - appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) - appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) - return false - } catch (e: GplayHttpRequestException) { - handleUpdateDownloadError( - appInstall, - "${appInstall.packageName} code: ${e.status} exception: ${e.localizedMessage}", - e - ) - return false - } catch (e: IllegalStateException) { - Timber.e(e) - } catch (e: Exception) { - handleUpdateDownloadError( - appInstall, - "${appInstall.packageName} exception: ${e.localizedMessage}", - e - ) - return false } - return true - } - - private suspend fun handleAppRestricted(appInstall: AppInstall) { - appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) - appManager.addDownload(appInstall) - appManager.updateUnavailable(appInstall) - } - - private suspend fun handleUpdateDownloadError( - appInstall: AppInstall, - message: String, - e: Exception - ) { - Timber.e(e, "Updating download Urls failed for $message") - appEventDispatcher.dispatch( - AppEvent.UpdateEvent( - ResultSupreme.WorkError( - ResultStatus.UNKNOWN, - appInstall - ) - ) - ) - } - - private suspend fun updateFusedDownloadWithAppDownloadLink(appInstall: AppInstall) { - applicationRepository.updateFusedDownloadWithDownloadingInfo( - appInstall.source, - appInstall - ) } } diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt index 2d9c73f7d..e1b8d9b26 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt @@ -24,12 +24,12 @@ import foundation.e.apps.data.install.AppInstallRepository import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.download.DownloadManagerUtils import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.transformWhile import timber.log.Timber import javax.inject.Inject -@Suppress("TooGenericExceptionCaught") // FIXME: Remove suppression and fix detekt class AppInstallWorkRunner @Inject constructor( private val appInstallRepository: AppInstallRepository, private val appManagerWrapper: AppManagerWrapper, @@ -43,45 +43,53 @@ class AppInstallWorkRunner @Inject constructor( runInForeground: suspend (String) -> Unit ): Result { var appInstall: AppInstall? = null - try { + runCatching { Timber.d("Fused download name $fusedDownloadId") appInstall = appInstallRepository.getDownloadById(fusedDownloadId) Timber.i(">>> doWork started for Fused download name ${appInstall?.name} $fusedDownloadId") appInstall?.let { - checkDownloadingState(appInstall) + checkDownloadingState(it) val isUpdateWork = - isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(appInstall) + isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(it) - if (!appInstall.isAppInstalling()) { + if (!it.isAppInstalling()) { Timber.d("!!! returned") return@let } - if (!appManagerWrapper.validateFusedDownload(appInstall)) { + if (!appManagerWrapper.validateFusedDownload(it)) { appManagerWrapper.installationIssue(it) Timber.d("!!! installationIssue") return@let } - if (areFilesDownloadedButNotInstalled(appInstall)) { - Timber.i("===> Downloaded But not installed ${appInstall.name}") - appManagerWrapper.updateDownloadStatus(appInstall, Status.INSTALLING) + if (areFilesDownloadedButNotInstalled(it)) { + Timber.i("===> Downloaded But not installed ${it.name}") + appManagerWrapper.updateDownloadStatus(it, Status.INSTALLING) } runInForeground.invoke(it.name) startAppInstallationProcess(it, isUpdateWork) } - } catch (e: Exception) { - Timber.e( - e, - "Install worker is failed for ${appInstall?.packageName} exception: ${e.localizedMessage}" - ) - appInstall?.let { - appManagerWrapper.cancelDownload(appInstall) + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + Timber.e( + throwable, + "Install worker is failed for ${appInstall?.packageName} " + + "exception: ${throwable.localizedMessage}" + ) + appInstall?.let { + appManagerWrapper.cancelDownload(it) + } + } + + else -> throw throwable } } @@ -138,14 +146,22 @@ class AppInstallWorkRunner @Inject constructor( download: AppInstall, isUpdateWork: Boolean ) { - try { + runCatching { handleFusedDownloadStatus(download, isUpdateWork) - } catch (e: Exception) { - val message = - "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}" - Timber.e(e, message) - appManagerWrapper.installationIssue(download) - finishInstallation(download, isUpdateWork) + }.onFailure { throwable -> + when (throwable) { + is CancellationException -> throw throwable + is Exception -> { + val message = + "Handling install status is failed for ${download.packageName} " + + "exception: ${throwable.localizedMessage}" + Timber.e(throwable, message) + appManagerWrapper.installationIssue(download) + finishInstallation(download, isUpdateWork) + } + + else -> throw throwable + } } } diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt index 944a60f8c..a4354a89c 100644 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt @@ -32,6 +32,9 @@ import foundation.e.apps.data.install.AppManagerWrapper import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.notification.StorageNotificationManager import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate +import foundation.e.apps.data.install.workmanager.AppInstallDevicePreconditions +import foundation.e.apps.data.install.workmanager.AppInstallDownloadUrlRefresher +import foundation.e.apps.data.install.workmanager.AppInstallPreEnqueueChecker import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator import foundation.e.apps.data.install.workmanager.InstallWorkManager import foundation.e.apps.data.install.wrapper.NetworkStatusChecker @@ -68,6 +71,9 @@ class AppInstallStartCoordinatorTest { private lateinit var storageSpaceChecker: StorageSpaceChecker private lateinit var networkStatusChecker: NetworkStatusChecker private lateinit var appManager: AppManager + private lateinit var devicePreconditions: AppInstallDevicePreconditions + private lateinit var downloadUrlRefresher: AppInstallDownloadUrlRefresher + private lateinit var preflightChecker: AppInstallPreEnqueueChecker private lateinit var coordinator: AppInstallStartCoordinator @Before @@ -85,18 +91,32 @@ class AppInstallStartCoordinatorTest { appManager = mockk(relaxed = true) coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE coEvery { playStoreAuthStore.awaitAuthData() } returns null + downloadUrlRefresher = AppInstallDownloadUrlRefresher( + applicationRepository, + appManagerWrapper, + appEventDispatcher, + appManager + ) + devicePreconditions = AppInstallDevicePreconditions( + appManagerWrapper, + appEventDispatcher, + storageNotificationManager, + storageSpaceChecker, + networkStatusChecker + ) + preflightChecker = AppInstallPreEnqueueChecker( + downloadUrlRefresher, + appManagerWrapper, + appInstallAgeLimitGate, + devicePreconditions + ) coordinator = AppInstallStartCoordinator( context, + preflightChecker, appManagerWrapper, - applicationRepository, sessionRepository, playStoreAuthStore, - storageNotificationManager, - appInstallAgeLimitGate, - appEventDispatcher, - storageSpaceChecker, - networkStatusChecker, - appManager + appEventDispatcher ) } @@ -242,7 +262,7 @@ class AppInstallStartCoordinatorTest { } @Test - fun canEnqueue_keepsGoingWhenDownloadUrlRefreshThrowsIllegalState() = runTest { + fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsIllegalState() = runTest { val appInstall = createNativeInstall() coEvery { @@ -251,15 +271,11 @@ class AppInstallStartCoordinatorTest { appInstall ) } throws IllegalStateException("boom") - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true - every { networkStatusChecker.isNetworkAvailable() } returns true - every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L val result = coordinator.canEnqueue(appInstall) - assertTrue(result) - coVerify { appManagerWrapper.addDownload(appInstall) } + assertFalse(result) + coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } } private fun createPwaInstall(isFree: Boolean = true) = AppInstall( -- GitLab