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"
+ )
+}