diff --git a/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.kt b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..d4e7cbf8a26c6cda1f9dc2ac993baf9f45d3276a --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.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 AppInstallationModule { + + @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/core/AppInstallationFacade.kt b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt new file mode 100644 index 0000000000000000000000000000000000000000..4d4d7cc01ac6c9b475d7d17ea290188658363fc5 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt @@ -0,0 +1,75 @@ +/* + * 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.core + +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.install.AppInstallComponents +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.domain.model.install.Status +import javax.inject.Inject + +class AppInstallationFacade @Inject constructor( + private val appInstallComponents: AppInstallComponents, + private val installationEnqueuer: InstallationEnqueuer, + private val installationProcessor: InstallationProcessor, + private val installationRequest: InstallationRequest, +) { + /** + * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process. + * @param application represents the app info which will be installed + * @param isAnUpdate indicates the app is requested for update or not + * + */ + suspend fun initAppInstall( + application: Application, + isAnUpdate: Boolean = false + ): Boolean { + val appInstall = installationRequest.create(application) + + val isUpdate = isAnUpdate || + application.status == Status.UPDATABLE || + appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall) + + return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) + } + + /** + * Enqueues [AppInstall] into WorkManager to run app install process. Before enqueuing, + * It validates some corner cases + * @param appInstall represents the app downloading and installing related info, example- Installing Status, + * Url of the APK,OBB files are needed to be downloaded and installed etc. + * @param isAnUpdate indicates the app is requested for update or not + */ + suspend fun enqueueFusedDownload( + appInstall: AppInstall, + isAnUpdate: Boolean = false, + isSystemApp: Boolean = false + ): Boolean { + return installationEnqueuer.enqueue(appInstall, isAnUpdate, isSystemApp) + } + + suspend fun processInstall( + fusedDownloadId: String, + isItUpdateWork: Boolean, + runInForeground: (suspend (String) -> Unit) + ): Result { + return installationProcessor.processInstall(fusedDownloadId, isItUpdateWork, runInForeground) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt new file mode 100644 index 0000000000000000000000000000000000000000..a86aa3697a2ece2aafca612227fdd2f1b49133c8 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt @@ -0,0 +1,100 @@ +/* + * 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.core + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +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.core.helper.PreEnqueueChecker +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.workmanager.InstallWorkManager +import foundation.e.apps.data.install.wrapper.AppEventDispatcher +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 + +class InstallationEnqueuer @Inject constructor( + @ApplicationContext private val context: Context, + private val preEnqueueChecker: PreEnqueueChecker, + private val appManagerWrapper: AppManagerWrapper, + private val sessionRepository: SessionRepository, + private val playStoreAuthStore: PlayStoreAuthStore, + private val appEventDispatcher: AppEventDispatcher, +) { + suspend fun enqueue( + appInstall: AppInstall, + isAnUpdate: Boolean = false, + isSystemApp: Boolean = false + ): Boolean { + return runCatching { + dispatchAnonymousPaidAppWarning(appInstall, isSystemApp) + + val canEnqueue = canEnqueue(appInstall, isAnUpdate) + if (canEnqueue) { + appManagerWrapper.updateAwaiting(appInstall) + + // Use only for update work for now. For installation work, see InstallOrchestrator#observeDownloads() + if (isAnUpdate) { + val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) + InstallWorkManager.enqueueWork(context, appInstall, true) + Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName") + } + } else { + Timber.w("Can't enqueue ${appInstall.name}/${appInstall.packageName} for installation.") + } + + 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 + } + else -> throw throwable + } + } + } + + suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean { + return preEnqueueChecker.canEnqueue(appInstall, isAnUpdate) + } + + 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) + ) + } + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationProcessor.kt new file mode 100644 index 0000000000000000000000000000000000000000..22c2174cba317ade4561fff6fe80c5ad757ee3bc --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationProcessor.kt @@ -0,0 +1,191 @@ +/* + * 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.core + +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler +import foundation.e.apps.data.install.download.DownloadManagerUtils +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.domain.model.install.Status +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.flow.transformWhile +import timber.log.Timber +import javax.inject.Inject + +class InstallationProcessor @Inject constructor( + private val appInstallRepository: AppInstallRepository, + private val appManagerWrapper: AppManagerWrapper, + private val downloadManager: DownloadManagerUtils, + private val installationCompletionHandler: InstallationCompletionHandler, +) { + @Suppress("ReturnCount") + @OptIn(DelicateCoroutinesApi::class) + suspend fun processInstall( + fusedDownloadId: String, + isItUpdateWork: Boolean, + runInForeground: suspend (String) -> Unit + ): Result { + val appInstall = + appInstallRepository.getDownloadById(fusedDownloadId) ?: return Result.failure( + IllegalStateException("App can't be null here.") + ) + + Timber.i(">>> doWork() started for ${appInstall.name}/${appInstall.packageName}") + + checkDownloadingState(appInstall) + + if (!appInstall.isAppInstalling()) { + val message = "${appInstall.status} is in invalid state" + Timber.w(message) + + return Result.failure(IllegalStateException(message)) + } + + if (!appManagerWrapper.validateFusedDownload(appInstall)) { + appManagerWrapper.installationIssue(appInstall) + val message = "Installation issue for ${appInstall.name}/${appInstall.packageName}" + Timber.w(message) + + return Result.failure(IllegalStateException(message)) + } + + return runCatching { + val isUpdateWork = + isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(appInstall) + + if (areFilesDownloadedButNotInstalled(appInstall)) { + Timber.i("===> Downloaded But not installed ${appInstall.name}") + appManagerWrapper.updateDownloadStatus(appInstall, Status.INSTALLING) + } + + runInForeground.invoke(appInstall.name) + startAppInstallationProcess(appInstall, isUpdateWork) + Timber.i("doWork: RESULT SUCCESS: ${appInstall.name}") + + ResultStatus.OK + }.onFailure { exception -> + if (exception is CancellationException) { + throw exception + } + + Timber.e( + exception, + "Install worker failed for ${appInstall.packageName} exception: ${exception.message}" + ) + + appManagerWrapper.cancelDownload(appInstall) + } + } + + @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 + ) { + runCatching { + handleFusedDownloadStatus(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 + } + } + } + + 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) { + installationCompletionHandler.onInstallFinished(appInstall, isUpdateWork) + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt new file mode 100644 index 0000000000000000000000000000000000000000..eb351f0b15338cf7bd04aa2fbb133a9bf9e459d7 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.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.core + +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 InstallationRequest @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/main/java/foundation/e/apps/data/install/core/helper/AgeLimitGate.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/AgeLimitGate.kt new file mode 100644 index 0000000000000000000000000000000000000000..2023908a8e652f2d439cbe4b3623ad3847a93455 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/AgeLimitGate.kt @@ -0,0 +1,79 @@ +/* + * 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.core.helper + +import foundation.e.apps.R +import foundation.e.apps.data.ResultSupreme +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 + +class AgeLimitGate @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) + val isAllowed = when { + ageLimitValidationResult.data?.isValid == true -> true + ageLimitValidationResult.isSuccess() -> handleSuccessfulValidation( + ageLimitValidationResult, + appInstall.name + ) + + else -> { + appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc)) + false + } + } + + if (!isAllowed) { + appManagerWrapper.cancelDownload(appInstall) + } + + return isAllowed + } + + private suspend fun handleSuccessfulValidation( + ageLimitValidationResult: 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) { + val deferred = CompletableDeferred() + appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred)) + deferred.await() + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.kt new file mode 100644 index 0000000000000000000000000000000000000000..3ba545b68478d2d17e8f1009b4a43ecd00157b4d --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.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.core.helper + +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 DevicePreconditions @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/core/helper/DownloadUrlRefresher.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/DownloadUrlRefresher.kt new file mode 100644 index 0000000000000000000000000000000000000000..3b1bc916daf1cbf4e7e942346769881e562d132b --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/DownloadUrlRefresher.kt @@ -0,0 +1,117 @@ +/* + * 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.core.helper + +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.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.wrapper.AppEventDispatcher +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import kotlinx.coroutines.CancellationException +import timber.log.Timber +import javax.inject.Inject + +class DownloadUrlRefresher @Inject constructor( + private val applicationRepository: ApplicationRepository, + private val appInstallRepository: AppInstallRepository, + private val appManagerWrapper: AppManagerWrapper, + private val appEventDispatcher: AppEventDispatcher, + private val appManager: AppManager, +) { + suspend fun updateDownloadUrls(appInstall: AppInstall, isAnUpdate: Boolean): Boolean { + return runCatching { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + appInstall.source, + appInstall + ) + }.fold( + onSuccess = { true }, + onFailure = { throwable -> + handleUpdateDownloadFailure( + appInstall, + isAnUpdate, + throwable + ) + } + ) + } + + private suspend fun handleUpdateDownloadFailure( + appInstall: AppInstall, + isAnUpdate: Boolean, + throwable: Throwable + ): Boolean { + return when (throwable) { + is CancellationException -> throw throwable + is InternalException.AppNotPurchased -> { + handleAppNotPurchased(appInstall) + false + } + is Exception -> { + val message = if (throwable is GplayHttpRequestException) { + "${appInstall.packageName} code: ${throwable.status} exception: ${throwable.message}" + } else { + "${appInstall.packageName} exception: ${throwable.message}" + } + Timber.e(throwable, "Updating download URLS failed for $message") + handleUpdateDownloadError(appInstall, isAnUpdate) + false + } + + else -> throw throwable + } + } + + private suspend fun handleAppNotPurchased(appInstall: AppInstall) { + if (appInstall.isFree) { + appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall)) + appManager.addDownload(appInstall) + appManager.updateUnavailable(appInstall) + } else { + appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) + appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall)) + } + } + + private suspend fun handleUpdateDownloadError(appInstall: AppInstall, isAnUpdate: Boolean) { + // Insert into DB to reflect error state on UI. + // For example, install button's label will change to Install -> Cancel -> Retry + if (appInstallRepository.getDownloadById(appInstall.id) == null) { + appInstallRepository.addDownload(appInstall) + } + appManagerWrapper.installationIssue(appInstall) + + if (isAnUpdate) { + appEventDispatcher.dispatch( + AppEvent.UpdateEvent( + ResultSupreme.WorkError( + ResultStatus.UNKNOWN, + appInstall + ) + ) + ) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt new file mode 100644 index 0000000000000000000000000000000000000000..0fec3c300b1eabd0f439caacf35eca0a685e4ca0 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.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.core.helper + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.R +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 foundation.e.apps.domain.model.install.Status +import java.text.NumberFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +class InstallationCompletionHandler @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/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt new file mode 100644 index 0000000000000000000000000000000000000000..7d6cba8de04ef59f42ec429e8fcd297f7d54a6f3 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.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.core.helper + +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 PreEnqueueChecker @Inject constructor( + private val downloadUrlRefresher: DownloadUrlRefresher, + private val appManagerWrapper: AppManagerWrapper, + private val ageLimitGate: AgeLimitGate, + private val devicePreconditions: DevicePreconditions, +) { + suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean { + val hasUpdatedDownloadUrls = appInstall.type == Type.PWA || + downloadUrlRefresher.updateDownloadUrls(appInstall, isAnUpdate) + + val isDownloadAdded = hasUpdatedDownloadUrls && addDownload(appInstall) + val isAgeLimitAllowed = isDownloadAdded && ageLimitGate.allow(appInstall) + + return isAgeLimitAllowed && devicePreconditions.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/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt index f510a58f2e5eb578c77633452b4ae018741d61ef..58ad52e3d89ac657a44aee2ddf340ae9adaf1517 100644 --- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt @@ -20,7 +20,7 @@ import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.event.AppEvent import foundation.e.apps.data.event.EventBus import foundation.e.apps.data.gitlab.SystemAppsUpdatesRepository -import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.core.AppInstallationFacade import foundation.e.apps.data.login.repository.AuthenticatorRepository import foundation.e.apps.data.updates.UpdatesManagerRepository import foundation.e.apps.domain.model.User @@ -40,7 +40,7 @@ class UpdatesWorker @AssistedInject constructor( private val sessionRepository: SessionRepository, private val appPreferencesRepository: AppPreferencesRepository, private val authenticatorRepository: AuthenticatorRepository, - private val appInstallProcessor: AppInstallProcessor, + private val appInstallationFacade: AppInstallationFacade, private val blockedAppRepository: BlockedAppRepository, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, ) : CoroutineWorker(context, params) { @@ -235,7 +235,7 @@ class UpdatesWorker @AssistedInject constructor( response.add(Pair(fusedApp, false)) continue } - val status = appInstallProcessor.initAppInstall(fusedApp, true) + val status = appInstallationFacade.initAppInstall(fusedApp, true) response.add(Pair(fusedApp, status)) } return response 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 deleted file mode 100644 index e2d33d90c5b46cbd69db31b7c4f630276337c902..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt +++ /dev/null @@ -1,498 +0,0 @@ -/* - * Copyright MURENA SAS 2023 - * Apps Quickly and easily install Android apps onto your device! - * - * 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 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.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.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.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 -import foundation.e.apps.domain.model.User -import foundation.e.apps.domain.model.install.Status -import foundation.e.apps.domain.preferences.SessionRepository -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") -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, -) { - @Inject - lateinit var downloadManager: DownloadManagerUtils - - @Inject - lateinit var appManager: AppManager - - private var isItUpdateWork = false - - companion object { - private const val TAG = "AppInstallProcessor" - private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm" - } - - /** - * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process. - * @param application represents the app info which will be installed - * @param isAnUpdate indicates the app is requested for update or not - * - */ - suspend fun initAppInstall( - 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 isUpdate = isAnUpdate || - application.status == Status.UPDATABLE || - appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall) - - return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp) - } - - /** - * Enqueues [AppInstall] into WorkManager to run app install process. Before enqueuing, - * It validates some corner cases - * @param appInstall represents the app downloading and installing related info, example- Installing Status, - * Url of the APK,OBB files are needed to be downloaded and installed etc. - * @param isAnUpdate indicates the app is requested for update or not - */ - suspend fun enqueueFusedDownload( - appInstall: AppInstall, - isAnUpdate: Boolean = false, - isSystemApp: Boolean = false - ): Boolean { - val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName) - - return try { - val user = sessionRepository.awaitUser() - if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) { - val authData = playStoreAuthStore.awaitAuthData() - if (!appInstall.isFree && authData?.isAnonymous == true) { - EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message)) - } - } - - if (!canEnqueue(appInstall)) return false - - appInstallComponents.appManagerWrapper.updateAwaiting(appInstall) - - // Use only for update work for now. For installation work, see InstallOrchestrator#observeDownloads() - if (isAnUpdate) { - InstallWorkManager.enqueueWork(context, appInstall, true) - Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName") - } - true - } catch (e: Exception) { - Timber.e(e, "UPDATE: Failed to enqueue unique work for ${appInstall.packageName}") - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - false - } - } - - @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 (!validateAgeLimit(appInstall)) { - return false - } - - if (!context.isNetworkAvailable()) { - appInstallComponents.appManagerWrapper.installationIssue(appInstall) - EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) - return false - } - - if (StorageComputer.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)) - return false - } - - 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 = ParentalControlAuthenticator.awaitAuthentication() - if (isAuthenticated) { - ageLimitValidationResult.setData(ContentRatingValidity(true)) - } - } - } else { - EventBus.invokeEvent(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() - EventBus.invokeEvent(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 { - updateFusedDownloadWithAppDownloadLink(appInstall) - } catch (e: InternalException.AppNotPurchased) { - if (appInstall.isFree) { - handleAppRestricted(appInstall) - return false - } - appInstallComponents.appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) - EventBus.invokeEvent(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) { - EventBus.invokeEvent(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") - EventBus.invokeEvent( - AppEvent.UpdateEvent( - ResultSupreme.WorkError( - ResultStatus.UNKNOWN, - appInstall - ) - ) - ) - } - - private suspend fun updateFusedDownloadWithAppDownloadLink( - appInstall: AppInstall - ) { - applicationRepository.updateFusedDownloadWithDownloadingInfo( - appInstall.source, - appInstall - ) - } - - @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 = appInstallComponents.appInstallRepository.getDownloadById(fusedDownloadId) - Timber.i(">>> dowork started for Fused download name " + appInstall?.name + " " + fusedDownloadId) - - appInstall?.let { - checkDownloadingState(appInstall) - - this.isItUpdateWork = - 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) - } - } 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? - ) { - if (isItUpdateWork) { - appInstall?.let { - val packageStatus = - appInstallComponents.appManagerWrapper.getFusedDownloadPackageStatus(appInstall) - - if (packageStatus == Status.INSTALLED) { - UpdatesDao.addSuccessfullyUpdatedApp(it) - } - - if (isUpdateCompleted()) { // show notification for ended update - showNotificationOnUpdateEnded() - UpdatesDao.clearSuccessfullyUpdatedApps() - } - } - } - } - - private suspend fun isUpdateCompleted(): Boolean { - val downloadListWithoutAnyIssue = - appInstallComponents.appInstallRepository.getDownloadList().filter { - !listOf( - Status.INSTALLATION_ISSUE, - Status.PURCHASE_NEEDED - ).contains(it.status) - } - - return UpdatesDao.successfulUpdatedApps.isNotEmpty() && 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) - .toString() - - UpdatesNotifier.showNotification( - context, - context.getString(R.string.update), - context.getString( - R.string.message_last_update_triggered, - numberOfUpdatedApps, - date - ) - ) - } - - private suspend fun startAppInstallationProcess(appInstall: AppInstall) { - if (appInstall.isAwaiting()) { - appInstallComponents.appManagerWrapper.downloadApp(appInstall, isItUpdateWork) - Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}") - } - - appInstallComponents.appInstallRepository.getDownloadFlowById(appInstall.id) - .transformWhile { - emit(it) - isInstallRunning(it) - } - .collect { latestFusedDownload -> - handleFusedDownload(latestFusedDownload, appInstall) - } - } - - /** - * 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 - ) { - if (latestAppInstall == null) { - Timber.d("===> download null: finish installation") - finishInstallation(appInstall) - return - } - - handleFusedDownloadStatusCheckingException(latestAppInstall) - } - - private fun isInstallRunning(it: AppInstall?) = - it != null && it.status != Status.INSTALLATION_ISSUE - - private suspend fun handleFusedDownloadStatusCheckingException( - download: AppInstall - ) { - try { - handleFusedDownloadStatus(download) - } 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) - } - } - - private suspend fun handleFusedDownloadStatus(appInstall: AppInstall) { - 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) - } - - else -> { - Timber.wtf( - TAG, - "===> ${appInstall.name} is in wrong state ${appInstall.status}" - ) - finishInstallation(appInstall) - } - } - } - - private suspend fun finishInstallation(appInstall: AppInstall) { - checkUpdateWork(appInstall) - } -} diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt index c4af7cfbb4a7bd717c6f2351a9923ecff40d439a..b095fb55f5d8740fe3f28a7623ff5500d565a2bc 100644 --- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt +++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt @@ -32,13 +32,14 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import foundation.e.apps.R +import foundation.e.apps.data.install.core.AppInstallationFacade import java.util.concurrent.atomic.AtomicInteger @HiltWorker class InstallAppWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted private val params: WorkerParameters, - private val appInstallProcessor: AppInstallProcessor + private val appInstallationFacade: AppInstallationFacade ) : CoroutineWorker(context, params) { companion object { @@ -59,15 +60,20 @@ class InstallAppWorker @AssistedInject constructor( override suspend fun doWork(): Result { val fusedDownloadId = params.inputData.getString(INPUT_DATA_FUSED_DOWNLOAD) ?: "" + if (fusedDownloadId.isEmpty()) { + return Result.failure() + } + val isPackageUpdate = params.inputData.getBoolean(IS_UPDATE_WORK, false) - val response = appInstallProcessor.processInstall(fusedDownloadId, isPackageUpdate) { title -> - setForeground( - createForegroundInfo( - "${context.getString(R.string.installing)} $title" - ) - ) + val response = appInstallationFacade.processInstall(fusedDownloadId, isPackageUpdate) { title -> + setForeground(createForegroundInfo("${context.getString(R.string.installing)} $title")) + } + + return if (response.isSuccess) { + Result.success() + } else { + Result.failure() } - return if (response.isSuccess) Result.success() else Result.failure() } private fun createForegroundInfo(progress: String): ForegroundInfo { 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 0000000000000000000000000000000000000000..e9c44ea58417ed1985b39920b397e580c25928a1 --- /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 0000000000000000000000000000000000000000..612ed2b0c228746fa40ba189f9428890dbde9e40 --- /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 0000000000000000000000000000000000000000..36829868b6b17949ee3dd40e0d0cd7875b2e5de3 --- /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 0000000000000000000000000000000000000000..e4b330af91f0bbba49e2aaeb3d7414ababd9b237 --- /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 0000000000000000000000000000000000000000..97a226c939bed2ebbc520d641ecffd93f2aa170c --- /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 0000000000000000000000000000000000000000..26bf2e1bad18f21f6d3f9f24d2ce394bc02bc7c5 --- /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/main/java/foundation/e/apps/ui/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt index b5deaa4eecc27ba67149370d09118ddff63b0b7d..2f7a52a0fab1217d15834f336670b74d828dd203 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -36,10 +36,10 @@ import foundation.e.apps.data.enums.isInitialized import foundation.e.apps.data.enums.isUnFiltered import foundation.e.apps.data.gitlab.SystemAppsUpdatesRepository import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.core.AppInstallationFacade import foundation.e.apps.data.install.models.AppInstall import foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.install.pkg.PwaManager -import foundation.e.apps.data.install.workmanager.AppInstallProcessor import foundation.e.apps.data.login.core.AuthObject import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository @@ -64,7 +64,7 @@ class MainActivityViewModel @Inject constructor( private val blockedAppRepository: BlockedAppRepository, private val gPlayContentRatingRepository: GPlayContentRatingRepository, private val fDroidAntiFeatureRepository: FDroidAntiFeatureRepository, - private val appInstallProcessor: AppInstallProcessor, + private val appInstallationFacade: AppInstallationFacade, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, private val reportFaultyTokenUseCase: ReportFaultyTokenUseCase, ) : ViewModel() { @@ -271,7 +271,7 @@ class MainActivityViewModel @Inject constructor( fun getApplication(app: Application) { viewModelScope.launch(Dispatchers.IO) { - appInstallProcessor.initAppInstall(app) + appInstallationFacade.initAppInstall(app) } } @@ -283,7 +283,7 @@ class MainActivityViewModel @Inject constructor( val fusedDownload = appManagerWrapper.getFusedDownload(packageName = packageName) val authData = sessionRepository.getAuthData() if (!authData.isAnonymous) { - appInstallProcessor.enqueueFusedDownload(fusedDownload) + appInstallationFacade.enqueueFusedDownload(fusedDownload) return fusedDownload } diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt index 3e53b5c0465d9e02cfcdc63a18c50e94d5657437..9726c147369869b8dfcbf7e524c275e3539abd8a 100644 --- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt @@ -50,7 +50,7 @@ import foundation.e.apps.data.login.core.StoreType import foundation.e.apps.data.login.repository.AuthenticatorRepository import foundation.e.apps.data.preference.SessionDataStore import foundation.e.apps.data.updates.UpdatesManagerRepository -import foundation.e.apps.data.install.workmanager.AppInstallProcessor +import foundation.e.apps.data.install.core.AppInstallationFacade import foundation.e.apps.domain.model.LoginState import foundation.e.apps.domain.model.User import foundation.e.apps.domain.preferences.AppPreferencesRepository @@ -96,7 +96,7 @@ class UpdatesWorkerTest { val appLoungeDataStore = createDataStore(dataStoreContext) val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val authData = AuthData(email = "user@example.com") @@ -151,7 +151,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -176,7 +176,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val authData = AuthData(email = "user@example.com") @@ -237,7 +237,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -247,7 +247,7 @@ class UpdatesWorkerTest { assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) verify(updatesManagerRepository, times(UpdatesWorker.MAX_RETRY_COUNT.plus(1))).getUpdates() verify(updatesManagerRepository, never()).getUpdatesOSS() - verify(appInstallProcessor, never()).initAppInstall(any(), any()) + verify(appInstallationFacade, never()).initAppInstall(any(), any()) } @Test @@ -258,7 +258,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val appLoungeDataStore = createDataStore(dataStoreContext) val authenticatorRepository = AuthenticatorRepository(emptyList(), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val notificationManager = mock() @@ -279,7 +279,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ), @@ -306,7 +306,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val appLoungeDataStore = createDataStore(appContext) val authenticatorRepository = AuthenticatorRepository(emptyList(), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -316,7 +316,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -335,7 +335,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val worker = createWorker( @@ -344,7 +344,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -366,7 +366,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val worker = createWorker( @@ -375,7 +375,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -414,7 +414,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val appLoungeDataStore = createDataStore(appContext) val authenticatorRepository = AuthenticatorRepository(emptyList(), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -424,7 +424,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -446,7 +446,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val sessionDataStore = mock() val authenticatorRepository = mock() - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -464,7 +464,7 @@ class UpdatesWorkerTest { updatesManagerRepository, sessionDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, createAppPreferencesRepository( @@ -489,7 +489,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val sessionDataStore = mock() val authenticatorRepository = mock() - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -508,7 +508,7 @@ class UpdatesWorkerTest { updatesManagerRepository, sessionDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, createAppPreferencesRepository( @@ -537,7 +537,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val sessionDataStore = mock() val authenticatorRepository = mock() - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -582,7 +582,7 @@ class UpdatesWorkerTest { ) whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true)) .thenReturn(ResultSupreme.Success(Unit)) - whenever(appInstallProcessor.initAppInstall(any(), any())).thenReturn(true) + whenever(appInstallationFacade.initAppInstall(any(), any())).thenReturn(true) val worker = createWorker( workerContext, @@ -590,7 +590,7 @@ class UpdatesWorkerTest { updatesManagerRepository, sessionDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository, createAppPreferencesRepository( @@ -603,7 +603,7 @@ class UpdatesWorkerTest { val result = worker.doWork() assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) - verify(appInstallProcessor).initAppInstall(any(), any()) + verify(appInstallationFacade).initAppInstall(any(), any()) } @Test @@ -618,7 +618,7 @@ class UpdatesWorkerTest { val updatesManagerRepository = mock() val sessionDataStore = mock() val authenticatorRepository = mock() - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -670,7 +670,7 @@ class UpdatesWorkerTest { updatesManagerRepository, sessionDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -678,7 +678,7 @@ class UpdatesWorkerTest { val result = worker.doWork() assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) - verify(appInstallProcessor, never()).initAppInstall(any(), any()) + verify(appInstallationFacade, never()).initAppInstall(any(), any()) } @@ -697,7 +697,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -725,7 +725,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -754,7 +754,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -782,7 +782,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -806,7 +806,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -824,14 +824,14 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) val paidApp = Application(name = "Paid", isFree = false) val freeApp = Application(name = "Free", isFree = true) - whenever(appInstallProcessor.initAppInstall(freeApp, true)).thenReturn(true) + whenever(appInstallationFacade.initAppInstall(freeApp, true)).thenReturn(true) val result = worker.startUpdateProcess(listOf(paidApp, freeApp)) @@ -839,8 +839,8 @@ class UpdatesWorkerTest { Pair(paidApp, false), Pair(freeApp, true) ) - verify(appInstallProcessor, times(1)).initAppInstall(freeApp, true) - verify(appInstallProcessor, times(0)).initAppInstall(paidApp, true) + verify(appInstallationFacade, times(1)).initAppInstall(freeApp, true) + verify(appInstallationFacade, times(0)).initAppInstall(paidApp, true) } @Test @@ -853,7 +853,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() val authData = AuthData(email = "user@example.com", isAnonymous = false) @@ -871,18 +871,18 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) val paidApp = Application(name = "Paid", isFree = false) - whenever(appInstallProcessor.initAppInstall(paidApp, true)).thenReturn(false) + whenever(appInstallationFacade.initAppInstall(paidApp, true)).thenReturn(false) val result = worker.startUpdateProcess(listOf(paidApp)) assertThat(result).containsExactly(Pair(paidApp, false)) - verify(appInstallProcessor, times(1)).initAppInstall(paidApp, true) + verify(appInstallationFacade, times(1)).initAppInstall(paidApp, true) } @Test @@ -894,7 +894,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -909,7 +909,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -944,7 +944,7 @@ class UpdatesWorkerTest { val storeAuthenticator = mockk() val authenticatorRepository = AuthenticatorRepository(listOf(storeAuthenticator), appLoungeDataStore) - val appInstallProcessor = mock() + val appInstallationFacade = mock() val blockedAppRepository = mock() val systemAppsUpdatesRepository = mock() @@ -959,7 +959,7 @@ class UpdatesWorkerTest { updatesManagerRepository, appLoungeDataStore, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) @@ -994,7 +994,7 @@ class UpdatesWorkerTest { updatesManagerRepository: UpdatesManagerRepository, sessionRepository: SessionRepository, authenticatorRepository: AuthenticatorRepository, - appInstallProcessor: AppInstallProcessor, + appInstallationFacade: AppInstallationFacade, blockedAppRepository: BlockedAppRepository, systemAppsUpdatesRepository: SystemAppsUpdatesRepository, appPreferencesRepository: AppPreferencesRepository = createAppPreferencesRepository(), @@ -1006,7 +1006,7 @@ class UpdatesWorkerTest { sessionRepository, appPreferencesRepository, authenticatorRepository, - appInstallProcessor, + appInstallationFacade, blockedAppRepository, systemAppsUpdatesRepository ) diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AgeLimitGateTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AgeLimitGateTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8eccf4606d9859f89b5a01989ba46ec091a80fde --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AgeLimitGateTest.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.core.helper.AgeLimitGate +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 AgeLimitGateTest { + private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var gate: AgeLimitGate + + @Before + fun setup() { + validateAppAgeLimitUseCase = Mockito.mock(ValidateAppAgeLimitUseCase::class.java) + appManagerWrapper = mockk(relaxed = true) + parentalControlAuthGateway = mockk(relaxed = true) + appEventDispatcher = FakeAppEventDispatcher(autoCompleteDeferred = true) + gate = AgeLimitGate( + 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 deleted file mode 100644 index 84e01ea3c8dcabc352a96cc542741d5af9ad02c4..0000000000000000000000000000000000000000 --- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt +++ /dev/null @@ -1,716 +0,0 @@ -/* - * Copyright MURENA SAS 2023 - * Apps Quickly and easily install Android apps onto your device! - * - * 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 android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.work.Operation -import com.google.common.util.concurrent.Futures -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.application.data.Application -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.enums.Type -import foundation.e.apps.domain.model.install.Status -import foundation.e.apps.data.fdroid.FDroidRepository -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.AppInstallProcessor -import foundation.e.apps.data.preference.PlayStoreAuthStore -import foundation.e.apps.data.install.workmanager.InstallWorkManager -import foundation.e.apps.data.system.StorageComputer -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.every -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.verify -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 -import org.junit.Assert.assertTrue -import org.junit.After -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 org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -@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 - - @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 - - @Mock - private lateinit var validateAppAgeRatingUseCase: ValidateAppAgeLimitUseCase - - @Mock - private lateinit var storageNotificationManager: StorageNotificationManager - - private var isInstallWorkManagerMocked = false - - @Before - fun setup() { - MockitoAnnotations.openMocks(this) - fakeFusedDownloadDAO = FakeAppInstallDAO() - appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO) - fakeFusedManagerRepository = - FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository) - val appInstallComponents = - AppInstallComponents(appInstallRepository, fakeFusedManagerRepository) - - appInstallProcessor = AppInstallProcessor( - context, - appInstallComponents, - applicationRepository, - validateAppAgeRatingUseCase, - sessionRepository, - playStoreAuthStore, - storageNotificationManager - ) - } - - @After - fun teardown() { - if (isInstallWorkManagerMocked) { - unmockkObject(InstallWorkManager) - isInstallWorkManagerMocked = false - } - } - - @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 canEnqueue_returnsTrueWhenAllChecksPass() = runTest { - val appInstall = AppInstall( - type = Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - 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() - every { StorageComputer.spaceMissing(appInstall) } returns 0 - - val result = processor.canEnqueue(appInstall) - - assertTrue(result) - coVerify { appManagerWrapper.addDownload(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { - val appInstall = AppInstall( - type = Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - 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() - every { StorageComputer.spaceMissing(appInstall) } returns 0 - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { - val appInstall = AppInstall( - type = Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - 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() - 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) - } - } - - @Test - fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest { - val appInstall = AppInstall( - type = Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - 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() - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun canEnqueue_returnsFalseWhenAgeLimitInvalid() = runTest { - val appInstall = AppInstall( - type = Type.PWA, - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("apk"), - packageName = "com.example.app" - ) - - 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() - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.canEnqueue(appInstall) - - assertEquals(false, result) - coVerify { appManagerWrapper.cancelDownload(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun enqueueFusedDownload_returnsTrueAndEnqueuesWorkForUpdate() = runTest { - val appInstall = createEnqueueAppInstall() - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerSuccess() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true) - - assertTrue(result) - coVerify { appManagerWrapper.updateAwaiting(appInstall) } - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun enqueueFusedDownload_returnsFalseAndMarksIssueWhenUpdateEnqueueFails() = runTest { - val appInstall = createEnqueueAppInstall() - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerFailure() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true) - - assertEquals(false, result) - coVerify { appManagerWrapper.updateAwaiting(appInstall) } - coVerify { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun enqueueFusedDownload_returnsTrueWithoutEnqueueingWorkForRegularInstall() = runTest { - val appInstall = createEnqueueAppInstall() - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerSuccess() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true) - - assertTrue(result) - coVerify { appManagerWrapper.updateAwaiting(appInstall) } - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun enqueueFusedDownload_skipsWorkManagerFailurePathForRegularInstall() = runTest { - val appInstall = createEnqueueAppInstall() - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerFailure() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true) - - assertTrue(result) - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun initAppInstall_enqueuesUpdateWorkWhenExplicitFlagIsTrue() = runTest { - val application = createApplication(status = Status.INSTALLED) - val appInstall = createExpectedAppInstall(application) - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerSuccess() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.initAppInstall(application, isAnUpdate = true) - - assertTrue(result) - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun initAppInstall_enqueuesUpdateWorkWhenApplicationIsUpdatable() = runTest { - val application = createApplication(status = Status.UPDATABLE) - val appInstall = createExpectedAppInstall(application) - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerSuccess() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.initAppInstall(application, isAnUpdate = false) - - assertTrue(result) - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun initAppInstall_enqueuesUpdateWorkWhenAppIsAlreadyInstalled() = runTest { - val application = createApplication(status = Status.INSTALLED) - val appInstall = createExpectedAppInstall(application) - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerSuccess() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns true - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.initAppInstall(application, isAnUpdate = false) - - assertTrue(result) - verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) } - } finally { - unmockkObject(StorageComputer) - } - } - - @Test - fun initAppInstall_doesNotEnqueueWorkWhenInstallIsNotAnUpdate() = runTest { - val application = createApplication(status = Status.INSTALLED) - val appInstall = createExpectedAppInstall(application) - val appManagerWrapper = mockk(relaxed = true) - val processor = createProcessorForCanEnqueue(appManagerWrapper) - - mockInstallWorkManagerFailure() - mockkObject(StorageComputer) - try { - everyNetworkAvailable() - Mockito.`when`(validateAppAgeRatingUseCase(appInstall)) - .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true))) - every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false - coEvery { appManagerWrapper.addDownload(appInstall) } returns true - every { StorageComputer.spaceMissing(appInstall) } returns 0L - - val result = processor.initAppInstall(application, isAnUpdate = false) - - assertTrue(result) - verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } - } finally { - unmockkObject(StorageComputer) - } - } - - private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? { - appInstallProcessor.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" - ) - - private fun createProcessorForCanEnqueue( - appManagerWrapper: AppManagerWrapper - ): AppInstallProcessor { - val appInstallRepository = AppInstallRepository(FakeAppInstallDAO()) - val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) - return AppInstallProcessor( - context, - appInstallComponents, - applicationRepository, - validateAppAgeRatingUseCase, - sessionRepository, - playStoreAuthStore, - storageNotificationManager - ) - } - - private fun createEnqueueAppInstall() = AppInstall( - id = "123", - status = Status.AWAITING, - downloadURLList = mutableListOf("https://example.org/app.apk"), - packageName = "com.example.app", - type = Type.PWA, - source = Source.PWA - ) - - private fun createApplication(status: Status) = Application( - _id = "123", - name = "Test app", - package_name = "com.example.app", - status = status, - source = Source.PWA, - type = Type.PWA, - latest_version_code = 1L, - isFree = true, - isSystemApp = true, - url = "https://example.org/app.apk" - ) - - private fun createExpectedAppInstall(application: Application) = 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 (it.type == Type.PWA || application.source == Source.SYSTEM_APP) { - it.downloadURLList = mutableListOf(application.url) - } - } - - private fun mockInstallWorkManagerSuccess() { - mockkObject(InstallWorkManager) - isInstallWorkManagerMocked = true - every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } - every { InstallWorkManager.enqueueWork(any(), any(), any()) } returns successfulOperation() - } - - private fun mockInstallWorkManagerFailure() { - mockkObject(InstallWorkManager) - isInstallWorkManagerMocked = true - every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() } - every { InstallWorkManager.enqueueWork(any(), any(), any()) } throws RuntimeException("enqueue failed") - } - - private fun successfulOperation(): Operation { - val operation = mock() - whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS)) - return operation - } - - private fun 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) - } -} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..747bde6e29d9eff1a99d2acc4819a6dba5176822 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt @@ -0,0 +1,131 @@ +/* + * 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 + * 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 androidx.arch.core.executor.testing.InstantTaskExecutorRule +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.Type +import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.data.install.AppInstallComponents +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.core.AppInstallationFacade +import foundation.e.apps.data.install.core.InstallationRequest +import foundation.e.apps.data.install.core.InstallationEnqueuer +import foundation.e.apps.data.install.core.InstallationProcessor +import foundation.e.apps.util.MainCoroutineRule +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.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AppInstallationFacadeTest { + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var appInstallationFacade: AppInstallationFacade + private lateinit var installationRequest: InstallationRequest + private lateinit var installationEnqueuer: InstallationEnqueuer + private lateinit var installationProcessor: InstallationProcessor + + @Before + fun setup() { + appManagerWrapper = mockk(relaxed = true) + val appInstallRepository = mockk(relaxed = true) + val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper) + installationRequest = mockk(relaxed = true) + installationEnqueuer = mockk(relaxed = true) + installationProcessor = mockk(relaxed = true) + + appInstallationFacade = AppInstallationFacade( + appInstallComponents, + installationEnqueuer, + installationProcessor, + installationRequest + ) + } + + @Test + fun initAppInstall_computesUpdateFlagAndDelegates() = runTest { + val application = Application( + _id = "123", + source = Source.PLAY_STORE, + status = Status.UPDATABLE, + name = "Example", + package_name = "com.example.app", + type = Type.NATIVE + ) + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationRequest.create(application) } returns appInstall + coEvery { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false + coEvery { + installationEnqueuer.enqueue( + appInstall, + true, + application.isSystemApp + ) + } returns true + + val result = appInstallationFacade.initAppInstall(application) + + assertTrue(result) + coVerify { installationRequest.create(application) } + coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) } + } + + @Test + fun enqueueFusedDownload_delegatesResult() = runTest { + val appInstall = AppInstall(id = "123", packageName = "com.example.app") + coEvery { installationEnqueuer.enqueue(appInstall, true, true) } returns false + + val result = appInstallationFacade.enqueueFusedDownload(appInstall, true, true) + + assertEquals(false, result) + coVerify { installationEnqueuer.enqueue(appInstall, true, true) } + } + + @Test + fun processInstall_delegatesResult() = runTest { + coEvery { + installationProcessor.processInstall("123", false, any()) + } returns Result.success(ResultStatus.OK) + + val result = appInstallationFacade.processInstall("123", false) { + // _ignored_ + } + + assertEquals(ResultStatus.OK, result.getOrNull()) + coVerify { installationProcessor.processInstall("123", false, any()) } + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..a4dc646e1b93bbc2c02b3ac79b6079d2ff8947f3 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt @@ -0,0 +1,118 @@ +/* + * 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.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.core.helper.DevicePreconditions +import foundation.e.apps.data.install.wrapper.NetworkStatusChecker +import foundation.e.apps.data.install.wrapper.StorageSpaceChecker +import foundation.e.apps.domain.model.install.Status +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +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 DevicePreconditionsTest { + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var storageNotificationManager: StorageNotificationManager + private lateinit var storageSpaceChecker: StorageSpaceChecker + private lateinit var networkStatusChecker: NetworkStatusChecker + private lateinit var preconditions: DevicePreconditions + + @Before + fun setup() { + appManagerWrapper = mockk(relaxed = true) + appEventDispatcher = FakeAppEventDispatcher() + storageNotificationManager = mockk(relaxed = true) + storageSpaceChecker = mockk(relaxed = true) + networkStatusChecker = mockk(relaxed = true) + preconditions = DevicePreconditions( + appManagerWrapper, + appEventDispatcher, + storageNotificationManager, + storageSpaceChecker, + networkStatusChecker + ) + } + + @Test + fun canProceed_returnsFalseWhenNetworkUnavailable() = runTest { + val appInstall = createInstall() + every { networkStatusChecker.isNetworkAvailable() } returns false + + val result = preconditions.canProceed(appInstall) + + assertFalse(result) + coVerify { appManagerWrapper.installationIssue(appInstall) } + verify(exactly = 0) { storageSpaceChecker.spaceMissing(any()) } + assertTrue(appEventDispatcher.events.any { + it is AppEvent.NoInternetEvent && it.data == false + }) + } + + @Test + fun canProceed_returnsFalseWhenStorageIsMissing() = runTest { + val appInstall = createInstall() + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 512L + + val result = preconditions.canProceed(appInstall) + + assertFalse(result) + verify { storageNotificationManager.showNotEnoughSpaceNotification(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + assertTrue(appEventDispatcher.events.any { + it is AppEvent.ErrorMessageEvent && it.data == R.string.not_enough_storage + }) + } + + @Test + fun canProceed_returnsTrueWhenNetworkAndStorageChecksPass() = runTest { + val appInstall = createInstall() + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = preconditions.canProceed(appInstall) + + assertTrue(result) + coVerify(exactly = 0) { appManagerWrapper.installationIssue(any()) } + verify(exactly = 0) { storageNotificationManager.showNotEnoughSpaceNotification(any()) } + assertTrue(appEventDispatcher.events.isEmpty()) + } + + private fun createInstall() = AppInstall( + id = "123", + status = Status.AWAITING, + name = "Example App", + packageName = "com.example.app", + appSize = 1024L + ) +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..5602e02e37d410d0f15622ddee9f4424f68c38e0 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt @@ -0,0 +1,181 @@ +/* + * 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.exceptions.InternalException +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.application.ApplicationRepository +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.core.helper.DownloadUrlRefresher +import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import foundation.e.apps.domain.model.install.Status +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.assertFailsWith +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DownloadUrlRefresherTest { + private lateinit var applicationRepository: ApplicationRepository + private lateinit var appInstallRepository: AppInstallRepository + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var appManager: AppManager + private lateinit var refresher: DownloadUrlRefresher + + @Before + fun setup() { + applicationRepository = mockk(relaxed = true) + appInstallRepository = mockk(relaxed = true) + appManagerWrapper = mockk(relaxed = true) + appEventDispatcher = FakeAppEventDispatcher() + appManager = mockk(relaxed = true) + refresher = DownloadUrlRefresher( + applicationRepository, + appInstallRepository, + appManagerWrapper, + appEventDispatcher, + appManager + ) + } + + @Test + fun updateDownloadUrls_returnsTrueWhenRefreshSucceeds() = runTest { + val appInstall = createNativeInstall() + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } returns Unit + + val result = refresher.updateDownloadUrls(appInstall, false) + + assertTrue(result) + coVerify(exactly = 0) { appManagerWrapper.installationIssue(any()) } + } + + @Test + fun updateDownloadUrls_handlesFreeAppNotPurchasedAsRestricted() = runTest { + val appInstall = createNativeInstall(isFree = true) + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws InternalException.AppNotPurchased() + + val result = refresher.updateDownloadUrls(appInstall, false) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.AppRestrictedOrUnavailable }) + coVerify { appManager.addDownload(appInstall) } + coVerify { appManager.updateUnavailable(appInstall) } + coVerify(exactly = 0) { appManagerWrapper.addFusedDownloadPurchaseNeeded(any()) } + } + + @Test + fun updateDownloadUrls_handlesPaidAppNotPurchasedAsPurchaseNeeded() = runTest { + val appInstall = createNativeInstall(isFree = false) + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws InternalException.AppNotPurchased() + + val result = refresher.updateDownloadUrls(appInstall, false) + + assertFalse(result) + coVerify { appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) } + assertTrue(appEventDispatcher.events.any { it is AppEvent.AppPurchaseEvent }) + coVerify(exactly = 0) { appManager.addDownload(any()) } + } + + @Test + fun updateDownloadUrls_recordsIssueWhenHttpRefreshFails() = runTest { + val appInstall = createNativeInstall() + coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws GplayHttpRequestException(403, "forbidden") + + val result = refresher.updateDownloadUrls(appInstall, false) + + assertFalse(result) + coVerify { appInstallRepository.addDownload(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + assertTrue(appEventDispatcher.events.none { it is AppEvent.UpdateEvent }) + } + + @Test + fun updateDownloadUrls_dispatchesUpdateEventWhenUpdateRefreshFails() = runTest { + val appInstall = createNativeInstall() + coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws IllegalStateException("boom") + + val result = refresher.updateDownloadUrls(appInstall, true) + + assertFalse(result) + coVerify { appInstallRepository.addDownload(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent }) + } + + @Test + fun updateDownloadUrls_doesNotDuplicateExistingDownloadOnFailure() = runTest { + val appInstall = createNativeInstall() + coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns appInstall + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws IllegalStateException("boom") + + val result = refresher.updateDownloadUrls(appInstall, false) + + assertFalse(result) + coVerify(exactly = 0) { appInstallRepository.addDownload(any()) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + } + + @Test + fun updateDownloadUrls_rethrowsCancellation() = runTest { + val appInstall = createNativeInstall() + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + } throws CancellationException("cancelled") + + assertFailsWith { + refresher.updateDownloadUrls(appInstall, false) + } + } + + 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 + ) +} 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 0000000000000000000000000000000000000000..b9ff56e859e6b38f557bd673a4d99c28961beef6 --- /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) + } + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..953424eb5aae7b7fae24e2bd5d3165923dae8108 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt @@ -0,0 +1,107 @@ +/* + * 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.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.Data +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.enums.ResultStatus +import foundation.e.apps.data.install.core.AppInstallationFacade +import foundation.e.apps.data.install.workmanager.InstallAppWorker +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +@OptIn(ExperimentalCoroutinesApi::class) +class InstallAppWorkerTest { + private lateinit var appInstallationFacade: AppInstallationFacade + + @Before + fun setup() { + appInstallationFacade = mockk(relaxed = true) + } + + @Test + fun doWork_returnsFailureWhenFusedDownloadIdIsMissing() = runTest { + val worker = createWorker(Data.EMPTY) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) + coVerify(exactly = 0) { appInstallationFacade.processInstall(any(), any(), any()) } + } + + @Test + fun doWork_returnsSuccessWhenProcessorSucceeds() = runTest { + coEvery { + appInstallationFacade.processInstall("123", true, any()) + } returns Result.success(ResultStatus.OK) + val worker = createWorker( + Data.Builder() + .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, "123") + .putBoolean(InstallAppWorker.IS_UPDATE_WORK, true) + .build() + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success()) + coVerify { appInstallationFacade.processInstall("123", true, any()) } + } + + @Test + fun doWork_returnsFailureWhenProcessorFails() = runTest { + coEvery { + appInstallationFacade.processInstall("123", false, any()) + } returns Result.failure(IllegalStateException("boom")) + val worker = createWorker( + Data.Builder() + .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, "123") + .putBoolean(InstallAppWorker.IS_UPDATE_WORK, false) + .build() + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure()) + coVerify { appInstallationFacade.processInstall("123", false, any()) } + } + + private fun createWorker(inputData: Data): InstallAppWorker { + val params = mockk(relaxed = true) + every { params.inputData } returns inputData + + return InstallAppWorker( + ApplicationProvider.getApplicationContext(), + params, + appInstallationFacade + ) + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..43cc6a355f94859ac3c841358d4e1b1a154db7ac --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.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.install.AppInstallRepository +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler +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.domain.model.install.Status +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 InstallationCompletionHandlerTest { + + @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: InstallationCompletionHandler + + @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 = InstallationCompletionHandler( + 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" + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..4e5ba464a2c8c1159b01e93407419d3507706170 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt @@ -0,0 +1,329 @@ +/* + * 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.Type +import foundation.e.apps.data.event.AppEvent +import foundation.e.apps.data.install.AppManager +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.notification.StorageNotificationManager +import foundation.e.apps.data.install.core.helper.AgeLimitGate +import foundation.e.apps.data.install.core.helper.DevicePreconditions +import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher +import foundation.e.apps.data.install.core.helper.PreEnqueueChecker +import foundation.e.apps.data.install.core.InstallationEnqueuer +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.model.install.Status +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 InstallationEnqueuerTest { + private lateinit var context: Context + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var applicationRepository: ApplicationRepository + private lateinit var appInstallRepository: AppInstallRepository + private lateinit var sessionRepository: SessionRepository + private lateinit var playStoreAuthStore: PlayStoreAuthStore + private lateinit var storageNotificationManager: StorageNotificationManager + private lateinit var ageLimitGate: AgeLimitGate + private lateinit var appEventDispatcher: FakeAppEventDispatcher + private lateinit var storageSpaceChecker: StorageSpaceChecker + private lateinit var networkStatusChecker: NetworkStatusChecker + private lateinit var appManager: AppManager + private lateinit var devicePreconditions: DevicePreconditions + private lateinit var downloadUrlRefresher: DownloadUrlRefresher + private lateinit var preflightChecker: PreEnqueueChecker + private lateinit var enqueuer: InstallationEnqueuer + + @Before + fun setup() { + context = mockk(relaxed = true) + appManagerWrapper = mockk(relaxed = true) + applicationRepository = mockk(relaxed = true) + appInstallRepository = mockk(relaxed = true) + sessionRepository = mockk(relaxed = true) + playStoreAuthStore = mockk(relaxed = true) + storageNotificationManager = mockk(relaxed = true) + ageLimitGate = 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 + downloadUrlRefresher = DownloadUrlRefresher( + applicationRepository, + appInstallRepository, + appManagerWrapper, + appEventDispatcher, + appManager + ) + devicePreconditions = DevicePreconditions( + appManagerWrapper, + appEventDispatcher, + storageNotificationManager, + storageSpaceChecker, + networkStatusChecker + ) + preflightChecker = PreEnqueueChecker( + downloadUrlRefresher, + appManagerWrapper, + ageLimitGate, + devicePreconditions + ) + enqueuer = InstallationEnqueuer( + context, + preflightChecker, + appManagerWrapper, + sessionRepository, + playStoreAuthStore, + appEventDispatcher + ) + } + + @Test + fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = enqueuer.canEnqueue(appInstall) + + assertTrue(result) + } + + @Test + fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns false + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + + val result = enqueuer.canEnqueue(appInstall) + + assertFalse(result) + coVerify { appManagerWrapper.installationIssue(appInstall) } + } + + @Test + fun canEnqueue_returnsFalseWhenStorageMissing() = runTest { + val appInstall = createPwaInstall() + + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L + + val result = enqueuer.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 = enqueuer.canEnqueue(appInstall) + + assertFalse(result) + coVerify(exactly = 0) { ageLimitGate.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 { ageLimitGate.allow(appInstall) } returns true + every { networkStatusChecker.isNetworkAvailable() } returns true + every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L + justRun { InstallWorkManager.enqueueWork(any(), any(), any()) } + + val result = enqueuer.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(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) } + } 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 = enqueuer.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 = enqueuer.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 { appInstallRepository.getDownloadById(appInstall.id) } returns null + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws GplayHttpRequestException(403, "forbidden") + + val result = enqueuer.canEnqueue(appInstall) + + assertFalse(result) + assertTrue(appEventDispatcher.events.none { it is AppEvent.UpdateEvent }) + coVerify { appInstallRepository.addDownload(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun canEnqueue_dispatchesUpdateEventWhenDownloadUrlRefreshFailsForUpdate() = runTest { + val appInstall = createNativeInstall() + + coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws GplayHttpRequestException(403, "forbidden") + + val result = enqueuer.canEnqueue(appInstall, true) + + assertFalse(result) + assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent }) + coVerify { appInstallRepository.addDownload(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) } + } + + @Test + fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsIllegalState() = runTest { + val appInstall = createNativeInstall() + + coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null + coEvery { + applicationRepository.updateFusedDownloadWithDownloadingInfo( + Source.PLAY_STORE, + appInstall + ) + } throws IllegalStateException("boom") + + val result = enqueuer.canEnqueue(appInstall) + + assertFalse(result) + coVerify { appInstallRepository.addDownload(appInstall) } + coVerify { appManagerWrapper.installationIssue(appInstall) } + coVerify(exactly = 0) { 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 + ) +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8212c636ed2e3018c160085dcf7cec33f86058aa --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt @@ -0,0 +1,231 @@ +/* + * 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.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.core.InstallationProcessor +import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler +import foundation.e.apps.domain.model.install.Status +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 InstallationProcessorTest { + @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 installationCompletionHandler: InstallationCompletionHandler + private lateinit var workRunner: InstallationProcessor + 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) + installationCompletionHandler = mockk(relaxed = true) + workRunner = InstallationProcessor( + appInstallRepository, + fakeFusedManagerRepository, + downloadManagerUtils, + installationCompletionHandler + ) + } + + @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_returnsFailureWhenInternalExceptionOccurs() = runTest { + val fusedDownload = initTest() + fakeFusedManagerRepository.forceCrash = true + + val result = workRunner.processInstall(fusedDownload.id, false) { + // _ignored_ + } + val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id) + + assertTrue(result.isFailure) + assertTrue(finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE) + } + + @Test + fun processInstall_returnsFailureWhenStatusIsInvalid() = runTest { + val fusedDownload = initTest() + fusedDownload.status = Status.BLOCKED + + val result = workRunner.processInstall(fusedDownload.id, false) { + // _ignored_ + } + val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id) + + assertTrue(result.isFailure) + assertEquals(Status.BLOCKED, finalFusedDownload?.status) + } + + @Test + fun processInstall_returnsFailureWhenDownloadMissing() = runTest { + val result = workRunner.processInstall("missing", false) { + // _ignored_ + } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @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" + ) +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..38495a31ac130bfca7c1b298e9098d561e47a827 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.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.Type +import foundation.e.apps.data.install.core.InstallationRequest +import foundation.e.apps.domain.model.install.Status +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InstallationRequestTest { + private lateinit var installationRequest: InstallationRequest + + @Before + fun setup() { + installationRequest = InstallationRequest() + } + + @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 = installationRequest.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 = installationRequest.create(application) + + assertEquals(contentRating, appInstall.contentRating) + } + + @Test + fun create_initializesDirectUrlForPwa() { + val application = Application(type = Type.PWA, url = "https://example.com") + + val appInstall = installationRequest.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 = installationRequest.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 = installationRequest.create(application) + + assertTrue(appInstall.downloadURLList.isEmpty()) + } +} diff --git a/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bd46bdb07037cb5437fdd14c2fefeef7f5fa2054 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt @@ -0,0 +1,144 @@ +/* + * 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.enums.Source +import foundation.e.apps.data.enums.Type +import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.data.install.core.helper.AgeLimitGate +import foundation.e.apps.data.install.core.helper.DevicePreconditions +import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher +import foundation.e.apps.data.install.core.helper.PreEnqueueChecker +import foundation.e.apps.domain.model.install.Status +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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PreEnqueueCheckerTest { + private lateinit var downloadUrlRefresher: DownloadUrlRefresher + private lateinit var appManagerWrapper: AppManagerWrapper + private lateinit var ageLimitGate: AgeLimitGate + private lateinit var devicePreconditions: DevicePreconditions + private lateinit var checker: PreEnqueueChecker + + @Before + fun setup() { + downloadUrlRefresher = mockk(relaxed = true) + appManagerWrapper = mockk(relaxed = true) + ageLimitGate = mockk(relaxed = true) + devicePreconditions = mockk(relaxed = true) + checker = PreEnqueueChecker( + downloadUrlRefresher, + appManagerWrapper, + ageLimitGate, + devicePreconditions + ) + } + + @Test + fun canEnqueue_skipsDownloadUrlRefreshForPwaInstalls() = runTest { + val appInstall = createPwaInstall() + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns true + coEvery { devicePreconditions.canProceed(appInstall) } returns true + + val result = checker.canEnqueue(appInstall) + + assertTrue(result) + coVerify(exactly = 0) { + downloadUrlRefresher.updateDownloadUrls(any(), any()) + } + } + + @Test + fun canEnqueue_stopsWhenDownloadRefreshFails() = runTest { + val appInstall = createNativeInstall() + coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns false + + val result = checker.canEnqueue(appInstall) + + assertFalse(result) + coVerify(exactly = 0) { appManagerWrapper.addDownload(any()) } + coVerify(exactly = 0) { ageLimitGate.allow(any()) } + coVerify(exactly = 0) { devicePreconditions.canProceed(any()) } + } + + @Test + fun canEnqueue_stopsWhenAddingDownloadFails() = runTest { + val appInstall = createNativeInstall() + coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true + coEvery { appManagerWrapper.addDownload(appInstall) } returns false + + val result = checker.canEnqueue(appInstall) + + assertFalse(result) + coVerify(exactly = 0) { ageLimitGate.allow(any()) } + coVerify(exactly = 0) { devicePreconditions.canProceed(any()) } + } + + @Test + fun canEnqueue_stopsWhenAgeLimitRejectsInstall() = runTest { + val appInstall = createNativeInstall() + coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns false + + val result = checker.canEnqueue(appInstall) + + assertFalse(result) + coVerify(exactly = 0) { devicePreconditions.canProceed(any()) } + } + + @Test + fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest { + val appInstall = createNativeInstall() + coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true + coEvery { appManagerWrapper.addDownload(appInstall) } returns true + coEvery { ageLimitGate.allow(appInstall) } returns true + coEvery { devicePreconditions.canProceed(appInstall) } returns true + + val result = checker.canEnqueue(appInstall) + + assertTrue(result) + } + + private fun createPwaInstall() = AppInstall( + type = Type.PWA, + id = "123", + status = Status.AWAITING, + downloadURLList = mutableListOf("apk"), + packageName = "com.example.app" + ) + + private fun createNativeInstall() = AppInstall( + type = Type.NATIVE, + source = Source.PLAY_STORE, + id = "123", + status = Status.AWAITING, + packageName = "com.example.app" + ) +}