diff --git a/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ed205c78b2b15d80d679745677bf055ef6d82aec
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallProcessorBindingsModule.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.di.bindings
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.install.wrapper.DefaultAppEventDispatcher
+import foundation.e.apps.data.install.wrapper.DeviceNetworkStatusChecker
+import foundation.e.apps.data.install.wrapper.NetworkStatusChecker
+import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
+import foundation.e.apps.data.install.wrapper.ParentalControlAuthGatewayImpl
+import foundation.e.apps.data.install.wrapper.StorageSpaceChecker
+import foundation.e.apps.data.install.wrapper.StorageSpaceCheckerImpl
+import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender
+import foundation.e.apps.data.install.wrapper.UpdatesNotificationSenderImpl
+import foundation.e.apps.data.install.wrapper.UpdatesTracker
+import foundation.e.apps.data.install.wrapper.UpdatesTrackerImpl
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface AppInstallProcessorBindingsModule {
+
+ @Binds
+ @Singleton
+ fun bindAppEventDispatcher(dispatcher: DefaultAppEventDispatcher): AppEventDispatcher
+
+ @Binds
+ @Singleton
+ fun bindStorageSpaceChecker(checker: StorageSpaceCheckerImpl): StorageSpaceChecker
+
+ @Binds
+ @Singleton
+ fun bindParentalControlAuthGateway(gateway: ParentalControlAuthGatewayImpl): ParentalControlAuthGateway
+
+ @Binds
+ @Singleton
+ fun bindUpdatesTracker(tracker: UpdatesTrackerImpl): UpdatesTracker
+
+ @Binds
+ @Singleton
+ fun bindUpdatesNotificationSender(sender: UpdatesNotificationSenderImpl): UpdatesNotificationSender
+
+ @Binds
+ @Singleton
+ fun bindNetworkStatusChecker(checker: DeviceNetworkStatusChecker): NetworkStatusChecker
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5962bc484ea9c7713b015840f5c0157a41a5cb7
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallAgeLimitGate.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import foundation.e.apps.R
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
+import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
+import foundation.e.apps.domain.model.ContentRatingValidity
+import kotlinx.coroutines.CompletableDeferred
+import javax.inject.Inject
+
+class AppInstallAgeLimitGate @Inject constructor(
+ private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val parentalControlAuthGateway: ParentalControlAuthGateway,
+) {
+ suspend fun allow(appInstall: AppInstall): Boolean {
+ val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall)
+ 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: foundation.e.apps.data.ResultSupreme,
+ appName: String
+ ): Boolean {
+ awaitInvokeAgeLimitEvent(appName)
+ if (ageLimitValidationResult.data?.requestPin == true &&
+ parentalControlAuthGateway.awaitAuthentication()
+ ) {
+ ageLimitValidationResult.setData(ContentRatingValidity(true))
+ }
+ return ageLimitValidationResult.data?.isValid == true
+ }
+
+ private suspend fun awaitInvokeAgeLimitEvent(type: String) {
+ val deferred = CompletableDeferred()
+ appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred))
+ deferred.await()
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e43499ad4f785553a0b8fc73a656b406ffc6986d
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDevicePreconditions.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import foundation.e.apps.R
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.notification.StorageNotificationManager
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.install.wrapper.NetworkStatusChecker
+import foundation.e.apps.data.install.wrapper.StorageSpaceChecker
+import timber.log.Timber
+import javax.inject.Inject
+
+class AppInstallDevicePreconditions @Inject constructor(
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val storageNotificationManager: StorageNotificationManager,
+ private val storageSpaceChecker: StorageSpaceChecker,
+ private val networkStatusChecker: NetworkStatusChecker,
+) {
+ suspend fun canProceed(appInstall: AppInstall): Boolean {
+ val hasNetwork = hasNetworkConnection(appInstall)
+ return hasNetwork && hasStorageSpace(appInstall)
+ }
+
+ private suspend fun hasNetworkConnection(appInstall: AppInstall): Boolean {
+ val hasNetwork = networkStatusChecker.isNetworkAvailable()
+ if (!hasNetwork) {
+ appManagerWrapper.installationIssue(appInstall)
+ appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false))
+ }
+ return hasNetwork
+ }
+
+ private suspend fun hasStorageSpace(appInstall: AppInstall): Boolean {
+ val missingStorage = storageSpaceChecker.spaceMissing(appInstall)
+ if (missingStorage > 0) {
+ Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}")
+ storageNotificationManager.showNotEnoughSpaceNotification(appInstall)
+ appManagerWrapper.installationIssue(appInstall)
+ appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage))
+ }
+ return missingStorage <= 0
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt
new file mode 100644
index 0000000000000000000000000000000000000000..281eb402217a5e3b5999e3676ca28384cf1e49b2
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallDownloadUrlRefresher.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import com.aurora.gplayapi.exceptions.InternalException
+import foundation.e.apps.data.ResultSupreme
+import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
+import kotlinx.coroutines.CancellationException
+import timber.log.Timber
+import javax.inject.Inject
+
+class AppInstallDownloadUrlRefresher @Inject constructor(
+ private val applicationRepository: ApplicationRepository,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val appManager: AppManager,
+) {
+ suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean {
+ return runCatching {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ appInstall.source,
+ appInstall
+ )
+ }.fold(
+ onSuccess = { true },
+ onFailure = { throwable -> handleUpdateDownloadFailure(appInstall, throwable) }
+ )
+ }
+
+ private suspend fun handleUpdateDownloadFailure(appInstall: AppInstall, throwable: Throwable): Boolean {
+ return when (throwable) {
+ is CancellationException -> throw throwable
+ is InternalException.AppNotPurchased -> handleAppNotPurchased(appInstall)
+ is GplayHttpRequestException -> {
+ handleUpdateDownloadError(
+ appInstall,
+ "${appInstall.packageName} code: ${throwable.status} exception: ${throwable.localizedMessage}",
+ throwable
+ )
+ false
+ }
+
+ is IllegalStateException -> {
+ Timber.e(throwable)
+ false
+ }
+
+ is Exception -> {
+ handleUpdateDownloadError(
+ appInstall,
+ "${appInstall.packageName} exception: ${throwable.localizedMessage}",
+ throwable
+ )
+ false
+ }
+
+ else -> throw throwable
+ }
+ }
+
+ private suspend fun handleAppNotPurchased(appInstall: AppInstall): Boolean {
+ if (appInstall.isFree) {
+ appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall))
+ appManager.addDownload(appInstall)
+ appManager.updateUnavailable(appInstall)
+ } else {
+ appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall)
+ appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall))
+ }
+ return false
+ }
+
+ private suspend fun handleUpdateDownloadError(
+ appInstall: AppInstall,
+ message: String,
+ exception: Exception
+ ) {
+ Timber.e(exception, "Updating download Urls failed for $message")
+ appEventDispatcher.dispatch(
+ AppEvent.UpdateEvent(
+ ResultSupreme.WorkError(
+ ResultStatus.UNKNOWN,
+ appInstall
+ )
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e1b3803505b3ae8dd7c9ab5f4cc1fc862769d278
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallPreEnqueueChecker.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import timber.log.Timber
+import javax.inject.Inject
+
+class AppInstallPreEnqueueChecker @Inject constructor(
+ private val appInstallDownloadUrlRefresher: AppInstallDownloadUrlRefresher,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appInstallAgeLimitGate: AppInstallAgeLimitGate,
+ private val appInstallDevicePreconditions: AppInstallDevicePreconditions,
+) {
+ suspend fun canEnqueue(appInstall: AppInstall): Boolean {
+ val hasUpdatedDownloadUrls = appInstall.type == Type.PWA ||
+ appInstallDownloadUrlRefresher.updateDownloadUrls(appInstall)
+
+ val isDownloadAdded = hasUpdatedDownloadUrls && addDownload(appInstall)
+ val isAgeLimitAllowed = isDownloadAdded && appInstallAgeLimitGate.allow(appInstall)
+
+ return isAgeLimitAllowed && appInstallDevicePreconditions.canProceed(appInstall)
+ }
+
+ private suspend fun addDownload(appInstall: AppInstall): Boolean {
+ val isDownloadAdded = appManagerWrapper.addDownload(appInstall)
+ if (!isDownloadAdded) {
+ Timber.i("Update adding ABORTED! status")
+ }
+
+ return isDownloadAdded
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt
index 3b214d475b3005c203a91edcb78c3155046a6480..20a7d7b9754ba1d39c1f2c7ac43cae798cbae919 100644
--- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt
@@ -18,68 +18,19 @@
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.Status
-import foundation.e.apps.data.enums.Type
-import foundation.e.apps.data.event.AppEvent
-import foundation.e.apps.data.event.EventBus
import foundation.e.apps.data.install.AppInstallComponents
-import foundation.e.apps.data.install.AppManager
-import foundation.e.apps.data.install.download.DownloadManagerUtils
import foundation.e.apps.data.install.models.AppInstall
-import foundation.e.apps.data.install.notification.StorageNotificationManager
-import foundation.e.apps.data.install.updates.UpdatesNotifier
-import foundation.e.apps.data.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.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,
+ private val appInstallStartCoordinator: AppInstallStartCoordinator,
+ private val appInstallWorkRunner: AppInstallWorkRunner,
+ private val appInstallRequestFactory: AppInstallRequestFactory,
) {
- @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
@@ -90,28 +41,7 @@ class AppInstallProcessor @Inject constructor(
application: Application,
isAnUpdate: Boolean = false
): Boolean {
- val appInstall = AppInstall(
- application._id,
- application.source,
- application.status,
- application.name,
- application.package_name,
- mutableListOf(),
- mutableMapOf(),
- application.status,
- application.type,
- application.icon_image_path,
- application.latest_version_code,
- application.offer_type,
- application.isFree,
- application.originalSize
- ).also {
- it.contentRating = application.contentRating
- }
-
- if (appInstall.type == Type.PWA || application.source == Source.SYSTEM_APP) {
- appInstall.downloadURLList = mutableListOf(application.url)
- }
+ val appInstall = appInstallRequestFactory.create(application)
val isUpdate = isAnUpdate ||
application.status == Status.UPDATABLE ||
@@ -132,363 +62,14 @@ class AppInstallProcessor @Inject constructor(
isAnUpdate: Boolean = false,
isSystemApp: Boolean = false
): Boolean {
- return try {
- val user = sessionRepository.awaitUser()
- if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) {
- val authData = playStoreAuthStore.awaitAuthData()
- if (!appInstall.isFree && authData?.isAnonymous == true) {
- EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message))
- }
- }
-
- if (!canEnqueue(appInstall)) return false
-
- appInstallComponents.appManagerWrapper.updateAwaiting(appInstall)
- InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate)
- true
- } catch (e: Exception) {
- Timber.e(
- e,
- "Enqueuing App install work is failed for ${appInstall.packageName} exception: ${e.localizedMessage}"
- )
- appInstallComponents.appManagerWrapper.installationIssue(appInstall)
- false
- }
- }
-
- @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
+ return appInstallStartCoordinator.enqueue(appInstall, isAnUpdate, isSystemApp)
}
- 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)
+ return appInstallWorkRunner.processInstall(fusedDownloadId, isItUpdateWork, runInForeground)
}
}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2045c39bf5204ede71b10bdf820a5d3f2757a18f
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallRequestFactory.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.install.models.AppInstall
+import javax.inject.Inject
+
+class AppInstallRequestFactory @Inject constructor() {
+ fun create(application: Application): AppInstall {
+ val appInstall = AppInstall(
+ application._id,
+ application.source,
+ application.status,
+ application.name,
+ application.package_name,
+ mutableListOf(),
+ mutableMapOf(),
+ application.status,
+ application.type,
+ application.icon_image_path,
+ application.latest_version_code,
+ application.offer_type,
+ application.isFree,
+ application.originalSize
+ ).also {
+ it.contentRating = application.contentRating
+ }
+
+ if (appInstall.type == Type.PWA || application.source == Source.SYSTEM_APP) {
+ appInstall.downloadURLList = mutableListOf(application.url)
+ }
+
+ return appInstall
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a88a1b967e1d2fedc28c8d48e8779c552d1ac742
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallStartCoordinator.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.R
+import foundation.e.apps.data.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.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 AppInstallStartCoordinator @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val appInstallPreEnqueueChecker: AppInstallPreEnqueueChecker,
+ 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)
+ if (canEnqueue) {
+ appManagerWrapper.updateAwaiting(appInstall)
+ InstallWorkManager.enqueueWork(context, appInstall, isAnUpdate)
+ }
+
+ 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): Boolean {
+ return appInstallPreEnqueueChecker.canEnqueue(appInstall)
+ }
+
+ 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/workmanager/AppInstallWorkRunner.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e1b8d9b267be6510232b5ad2da63050560e19943
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallWorkRunner.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.download.DownloadManagerUtils
+import foundation.e.apps.data.install.models.AppInstall
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.flow.transformWhile
+import timber.log.Timber
+import javax.inject.Inject
+
+class AppInstallWorkRunner @Inject constructor(
+ private val appInstallRepository: AppInstallRepository,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val downloadManager: DownloadManagerUtils,
+ private val appUpdateCompletionHandler: AppUpdateCompletionHandler,
+) {
+ @OptIn(DelicateCoroutinesApi::class)
+ suspend fun processInstall(
+ fusedDownloadId: String,
+ isItUpdateWork: Boolean,
+ runInForeground: suspend (String) -> Unit
+ ): Result {
+ var appInstall: AppInstall? = null
+ runCatching {
+ Timber.d("Fused download name $fusedDownloadId")
+
+ appInstall = appInstallRepository.getDownloadById(fusedDownloadId)
+ Timber.i(">>> doWork started for Fused download name ${appInstall?.name} $fusedDownloadId")
+
+ appInstall?.let {
+ checkDownloadingState(it)
+
+ val isUpdateWork =
+ isItUpdateWork && appManagerWrapper.isFusedDownloadInstalled(it)
+
+ if (!it.isAppInstalling()) {
+ Timber.d("!!! returned")
+ return@let
+ }
+
+ if (!appManagerWrapper.validateFusedDownload(it)) {
+ appManagerWrapper.installationIssue(it)
+ Timber.d("!!! installationIssue")
+ return@let
+ }
+
+ if (areFilesDownloadedButNotInstalled(it)) {
+ Timber.i("===> Downloaded But not installed ${it.name}")
+ appManagerWrapper.updateDownloadStatus(it, Status.INSTALLING)
+ }
+
+ runInForeground.invoke(it.name)
+
+ startAppInstallationProcess(it, isUpdateWork)
+ }
+ }.onFailure { throwable ->
+ when (throwable) {
+ is CancellationException -> throw throwable
+ is Exception -> {
+ Timber.e(
+ throwable,
+ "Install worker is failed for ${appInstall?.packageName} " +
+ "exception: ${throwable.localizedMessage}"
+ )
+ appInstall?.let {
+ appManagerWrapper.cancelDownload(it)
+ }
+ }
+
+ else -> throw throwable
+ }
+ }
+
+ Timber.i("doWork: RESULT SUCCESS: ${appInstall?.name}")
+ return Result.success(ResultStatus.OK)
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun checkDownloadingState(appInstall: AppInstall) {
+ if (appInstall.status == Status.DOWNLOADING) {
+ appInstall.downloadIdMap.keys.forEach { downloadId ->
+ downloadManager.updateDownloadStatus(downloadId)
+ }
+ }
+ }
+
+ private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall): Boolean = appInstall.areFilesDownloaded() &&
+ (!appManagerWrapper.isFusedDownloadInstalled(appInstall) || appInstall.status == Status.INSTALLING)
+
+ private suspend fun startAppInstallationProcess(appInstall: AppInstall, isUpdateWork: Boolean) {
+ if (appInstall.isAwaiting()) {
+ appManagerWrapper.downloadApp(appInstall, isUpdateWork)
+ Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}")
+ }
+
+ appInstallRepository.getDownloadFlowById(appInstall.id)
+ .transformWhile {
+ emit(it)
+ isInstallRunning(it)
+ }
+ .collect { latestFusedDownload ->
+ handleFusedDownload(latestFusedDownload, appInstall, isUpdateWork)
+ }
+ }
+
+ private suspend fun handleFusedDownload(
+ latestAppInstall: AppInstall?,
+ appInstall: AppInstall,
+ isUpdateWork: Boolean
+ ) {
+ if (latestAppInstall == null) {
+ Timber.d("===> download null: finish installation")
+ finishInstallation(appInstall, isUpdateWork)
+ return
+ }
+
+ handleFusedDownloadStatusCheckingException(latestAppInstall, isUpdateWork)
+ }
+
+ private fun isInstallRunning(it: AppInstall?) =
+ it != null && it.status != Status.INSTALLATION_ISSUE
+
+ private suspend fun handleFusedDownloadStatusCheckingException(
+ download: AppInstall,
+ isUpdateWork: Boolean
+ ) {
+ 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) {
+ appUpdateCompletionHandler.onInstallFinished(appInstall, isUpdateWork)
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a3dce75ca1c3ff796af0fd498d39735a15da0b57
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppUpdateCompletionHandler.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.workmanager
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.R
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender
+import foundation.e.apps.data.install.wrapper.UpdatesTracker
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.data.utils.getFormattedString
+import java.text.NumberFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+
+class AppUpdateCompletionHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val appInstallRepository: AppInstallRepository,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val playStoreAuthStore: PlayStoreAuthStore,
+ private val updatesTracker: UpdatesTracker,
+ private val updatesNotificationSender: UpdatesNotificationSender,
+) {
+ companion object {
+ private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm"
+ }
+
+ suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) {
+ if (!isUpdateWork) {
+ return
+ }
+
+ appInstall?.let {
+ val packageStatus = appManagerWrapper.getFusedDownloadPackageStatus(appInstall)
+
+ if (packageStatus == Status.INSTALLED) {
+ updatesTracker.addSuccessfullyUpdatedApp(it)
+ }
+
+ if (isUpdateCompleted()) {
+ showNotificationOnUpdateEnded()
+ updatesTracker.clearSuccessfullyUpdatedApps()
+ }
+ }
+ }
+
+ private suspend fun isUpdateCompleted(): Boolean {
+ val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter {
+ !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status)
+ }
+
+ return updatesTracker.hasSuccessfulUpdatedApps() && downloadListWithoutAnyIssue.isEmpty()
+ }
+
+ private suspend fun showNotificationOnUpdateEnded() {
+ val locale = playStoreAuthStore.awaitAuthData()?.locale ?: Locale.getDefault()
+ val date = Date().getFormattedString(DATE_FORMAT, locale)
+ val numberOfUpdatedApps =
+ NumberFormat.getNumberInstance(locale)
+ .format(updatesTracker.successfulUpdatedAppsCount())
+ .toString()
+
+ updatesNotificationSender.showNotification(
+ context.getString(R.string.update),
+ context.getString(
+ R.string.message_last_update_triggered,
+ numberOfUpdatedApps,
+ date
+ )
+ )
+ }
+}
diff --git a/app/src/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/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..19de4f0f836320f660e9f17e42df82fa57aadec2
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallAgeLimitGateTest.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import foundation.e.apps.R
+import foundation.e.apps.data.ResultSupreme
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate
+import foundation.e.apps.data.install.wrapper.ParentalControlAuthGateway
+import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
+import foundation.e.apps.domain.model.ContentRatingValidity
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppInstallAgeLimitGateTest {
+ private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var gate: AppInstallAgeLimitGate
+
+ @Before
+ fun setup() {
+ validateAppAgeLimitUseCase = Mockito.mock(ValidateAppAgeLimitUseCase::class.java)
+ appManagerWrapper = mockk(relaxed = true)
+ parentalControlAuthGateway = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher(autoCompleteDeferred = true)
+ gate = AppInstallAgeLimitGate(
+ validateAppAgeLimitUseCase,
+ appManagerWrapper,
+ appEventDispatcher,
+ parentalControlAuthGateway
+ )
+ }
+
+ @Test
+ fun allow_returnsTrueWhenAgeRatingIsValid() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
+
+ val result = gate.allow(appInstall)
+
+ assertTrue(result)
+ coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
+ }
+
+ @Test
+ fun allow_returnsFalseAndCancelsWhenAgeRatingInvalidWithoutPin() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false)))
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+
+ @Test
+ fun allow_returnsTrueWhenPinIsRequiredAndAuthenticationSucceeds() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(
+ ResultSupreme.create(
+ ResultStatus.OK,
+ ContentRatingValidity(false, requestPin = true)
+ )
+ )
+ coEvery { parentalControlAuthGateway.awaitAuthentication() } returns true
+
+ val result = gate.allow(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
+ coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
+ }
+
+ @Test
+ fun allow_returnsFalseWhenPinIsRequiredAndAuthenticationFails() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(
+ ResultSupreme.create(
+ ResultStatus.OK,
+ ContentRatingValidity(false, requestPin = true)
+ )
+ )
+ coEvery { parentalControlAuthGateway.awaitAuthentication() } returns false
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+
+ @Test
+ fun allow_dispatchesErrorDialogWhenValidationFails() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false)))
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ val errorDialogEvent = appEventDispatcher.events.last() as AppEvent.ErrorMessageDialogEvent
+ assertEquals(R.string.data_load_error_desc, errorDialogEvent.data)
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt
index a492a7377fbb7636b6d15bd605540bf2ca091bff..72384b6caa3060826faa03f6faa360c022da19de 100644
--- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright MURENA SAS 2023
+ * Copyright MURENA SAS 2026
* Apps Quickly and easily install Android apps onto your device!
*
* This program is free software: you can redistribute it and/or modify
@@ -18,34 +18,24 @@
package foundation.e.apps.installProcessor
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.Network
-import android.net.NetworkCapabilities
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import foundation.e.apps.data.ResultSupreme
-import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
-import foundation.e.apps.data.fdroid.FDroidRepository
+import foundation.e.apps.data.enums.Type
import foundation.e.apps.data.install.AppInstallComponents
import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.AppManager
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.models.AppInstall
-import foundation.e.apps.data.install.notification.StorageNotificationManager
import foundation.e.apps.data.install.workmanager.AppInstallProcessor
-import foundation.e.apps.data.preference.PlayStoreAuthStore
-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.data.install.workmanager.AppInstallRequestFactory
+import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator
+import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner
import foundation.e.apps.util.MainCoroutineRule
import io.mockk.coEvery
import io.mockk.coVerify
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
@@ -53,377 +43,89 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
-import org.mockito.Mock
-import org.mockito.Mockito
-import org.mockito.MockitoAnnotations
-import 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 appManagerWrapper: AppManagerWrapper
private lateinit var appInstallProcessor: AppInstallProcessor
-
- @Mock
- private lateinit var validateAppAgeRatingUseCase: ValidateAppAgeLimitUseCase
-
- @Mock
- private lateinit var storageNotificationManager: StorageNotificationManager
+ private lateinit var appInstallRequestFactory: AppInstallRequestFactory
+ private lateinit var appInstallStartCoordinator: AppInstallStartCoordinator
+ private lateinit var appInstallWorkRunner: AppInstallWorkRunner
@Before
fun setup() {
- MockitoAnnotations.openMocks(this)
- fakeFusedDownloadDAO = FakeAppInstallDAO()
- appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO)
- fakeFusedManagerRepository =
- FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository)
- val appInstallComponents =
- AppInstallComponents(appInstallRepository, fakeFusedManagerRepository)
+ appManagerWrapper = mockk(relaxed = true)
+ val appInstallRepository = mockk(relaxed = true)
+ val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper)
+ appInstallRequestFactory = mockk(relaxed = true)
+ appInstallStartCoordinator = mockk(relaxed = true)
+ appInstallWorkRunner = mockk(relaxed = true)
appInstallProcessor = AppInstallProcessor(
- context,
appInstallComponents,
- applicationRepository,
- validateAppAgeRatingUseCase,
- sessionRepository,
- playStoreAuthStore,
- storageNotificationManager
- )
- }
-
- @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 = foundation.e.apps.data.enums.Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
+ appInstallStartCoordinator,
+ appInstallWorkRunner,
+ appInstallRequestFactory
)
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0
-
- val result = processor.canEnqueue(appInstall)
-
- assertTrue(result)
- coVerify { appManagerWrapper.addDownload(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
}
@Test
- fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest {
- val appInstall = AppInstall(
- type = foundation.e.apps.data.enums.Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
+ 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 { appInstallRequestFactory.create(application) } returns appInstall
+ coEvery { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false
+ coEvery {
+ appInstallStartCoordinator.enqueue(
+ appInstall,
+ true,
+ application.isSystemApp
+ )
+ } returns true
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
+ val result = appInstallProcessor.initAppInstall(application)
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkUnavailable()
- io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- coVerify { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
+ assertTrue(result)
+ coVerify { appInstallRequestFactory.create(application) }
+ coVerify { appInstallStartCoordinator.enqueue(appInstall, true, application.isSystemApp) }
}
@Test
- fun canEnqueue_returnsFalseWhenStorageMissing() = runTest {
- val appInstall = AppInstall(
- type = foundation.e.apps.data.enums.Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 100L
+ fun enqueueFusedDownload_delegatesResult() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ coEvery { appInstallStartCoordinator.enqueue(appInstall, true, true) } returns false
- val result = processor.canEnqueue(appInstall)
+ val result = appInstallProcessor.enqueueFusedDownload(appInstall, true, true)
- assertEquals(false, result)
- Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall)
- coVerify { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
+ assertEquals(false, result)
+ coVerify { appInstallStartCoordinator.enqueue(appInstall, true, true) }
}
@Test
- fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest {
- val appInstall = AppInstall(
- type = foundation.e.apps.data.enums.Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
+ fun processInstall_delegatesResult() = runTest {
+ coEvery {
+ appInstallWorkRunner.processInstall("123", false, any())
+ } returns Result.success(ResultStatus.OK)
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns false
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- 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 = foundation.e.apps.data.enums.Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false)))
- everyNetworkAvailable()
- io.mockk.every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- coVerify { appManagerWrapper.cancelDownload(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? {
- appInstallProcessor.processInstall(appInstall.id, false) {
+ val result = appInstallProcessor.processInstall("123", 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 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)
+ assertEquals(ResultStatus.OK, result.getOrNull())
+ coVerify { appInstallWorkRunner.processInstall("123", false, any()) }
}
}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..261eabb2019dd0fcc97cf6a2f980a03aef82d7a4
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallRequestFactoryTest.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import com.aurora.gplayapi.data.models.ContentRating
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.install.workmanager.AppInstallRequestFactory
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class AppInstallRequestFactoryTest {
+ private lateinit var factory: AppInstallRequestFactory
+
+ @Before
+ fun setup() {
+ factory = AppInstallRequestFactory()
+ }
+
+ @Test
+ fun create_copiesExpectedFields() {
+ val application = Application(
+ _id = "123",
+ source = Source.PLAY_STORE,
+ status = Status.AWAITING,
+ name = "Example",
+ package_name = "com.example.app",
+ type = Type.NATIVE,
+ icon_image_path = "icon.png",
+ latest_version_code = 42,
+ offer_type = 1,
+ isFree = false,
+ originalSize = 2048L
+ )
+
+ val appInstall = factory.create(application)
+
+ assertEquals("123", appInstall.id)
+ assertEquals(Source.PLAY_STORE, appInstall.source)
+ assertEquals(Status.AWAITING, appInstall.status)
+ assertEquals("Example", appInstall.name)
+ assertEquals("com.example.app", appInstall.packageName)
+ assertEquals(Type.NATIVE, appInstall.type)
+ assertEquals("icon.png", appInstall.iconImageUrl)
+ assertEquals(42, appInstall.versionCode)
+ assertEquals(1, appInstall.offerType)
+ assertEquals(false, appInstall.isFree)
+ assertEquals(2048L, appInstall.appSize)
+ }
+
+ @Test
+ fun create_setsContentRating() {
+ val contentRating = ContentRating()
+ val application = Application(contentRating = contentRating)
+
+ val appInstall = factory.create(application)
+
+ assertEquals(contentRating, appInstall.contentRating)
+ }
+
+ @Test
+ fun create_initializesDirectUrlForPwa() {
+ val application = Application(type = Type.PWA, url = "https://example.com")
+
+ val appInstall = factory.create(application)
+
+ assertEquals(mutableListOf("https://example.com"), appInstall.downloadURLList)
+ }
+
+ @Test
+ fun create_initializesDirectUrlForSystemApp() {
+ val application = Application(source = Source.SYSTEM_APP, url = "file://app.apk")
+
+ val appInstall = factory.create(application)
+
+ assertEquals(mutableListOf("file://app.apk"), appInstall.downloadURLList)
+ }
+
+ @Test
+ fun create_doesNotForceDirectUrlForNativeNonSystemApp() {
+ val application =
+ Application(source = Source.PLAY_STORE, type = Type.NATIVE, url = "ignored")
+
+ val appInstall = factory.create(application)
+
+ assertTrue(appInstall.downloadURLList.isEmpty())
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a4354a89ca0d5d32970eac2b1328d2dd01a2e743
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallStartCoordinatorTest.kt
@@ -0,0 +1,298 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.content.Context
+import com.aurora.gplayapi.data.models.AuthData
+import com.aurora.gplayapi.exceptions.InternalException
+import foundation.e.apps.R
+import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.notification.StorageNotificationManager
+import foundation.e.apps.data.install.workmanager.AppInstallAgeLimitGate
+import foundation.e.apps.data.install.workmanager.AppInstallDevicePreconditions
+import foundation.e.apps.data.install.workmanager.AppInstallDownloadUrlRefresher
+import foundation.e.apps.data.install.workmanager.AppInstallPreEnqueueChecker
+import foundation.e.apps.data.install.workmanager.AppInstallStartCoordinator
+import foundation.e.apps.data.install.workmanager.InstallWorkManager
+import foundation.e.apps.data.install.wrapper.NetworkStatusChecker
+import foundation.e.apps.data.install.wrapper.StorageSpaceChecker
+import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.domain.model.User
+import foundation.e.apps.domain.preferences.SessionRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.justRun
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppInstallStartCoordinatorTest {
+ private lateinit var context: Context
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var applicationRepository: ApplicationRepository
+ private lateinit var sessionRepository: SessionRepository
+ private lateinit var playStoreAuthStore: PlayStoreAuthStore
+ private lateinit var storageNotificationManager: StorageNotificationManager
+ private lateinit var appInstallAgeLimitGate: AppInstallAgeLimitGate
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var storageSpaceChecker: StorageSpaceChecker
+ private lateinit var networkStatusChecker: NetworkStatusChecker
+ private lateinit var appManager: AppManager
+ private lateinit var devicePreconditions: AppInstallDevicePreconditions
+ private lateinit var downloadUrlRefresher: AppInstallDownloadUrlRefresher
+ private lateinit var preflightChecker: AppInstallPreEnqueueChecker
+ private lateinit var coordinator: AppInstallStartCoordinator
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ appManagerWrapper = mockk(relaxed = true)
+ applicationRepository = mockk(relaxed = true)
+ sessionRepository = mockk(relaxed = true)
+ playStoreAuthStore = mockk(relaxed = true)
+ storageNotificationManager = mockk(relaxed = true)
+ appInstallAgeLimitGate = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher()
+ storageSpaceChecker = mockk(relaxed = true)
+ networkStatusChecker = mockk(relaxed = true)
+ appManager = mockk(relaxed = true)
+ coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE
+ coEvery { playStoreAuthStore.awaitAuthData() } returns null
+ downloadUrlRefresher = AppInstallDownloadUrlRefresher(
+ applicationRepository,
+ appManagerWrapper,
+ appEventDispatcher,
+ appManager
+ )
+ devicePreconditions = AppInstallDevicePreconditions(
+ appManagerWrapper,
+ appEventDispatcher,
+ storageNotificationManager,
+ storageSpaceChecker,
+ networkStatusChecker
+ )
+ preflightChecker = AppInstallPreEnqueueChecker(
+ downloadUrlRefresher,
+ appManagerWrapper,
+ appInstallAgeLimitGate,
+ devicePreconditions
+ )
+ coordinator = AppInstallStartCoordinator(
+ context,
+ preflightChecker,
+ appManagerWrapper,
+ sessionRepository,
+ playStoreAuthStore,
+ appEventDispatcher
+ )
+ }
+
+ @Test
+ fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns false
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenStorageMissing() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ verify { storageNotificationManager.showNotEnoughSpaceNotification(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns false
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { appInstallAgeLimitGate.allow(any()) }
+ }
+
+ @Test
+ fun enqueue_warnsAnonymousPaidUsersWithoutAborting() = runTest {
+ val appInstall = createPwaInstall(isFree = false)
+
+ mockkObject(InstallWorkManager)
+ try {
+ coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS
+ coEvery {
+ playStoreAuthStore.awaitAuthData()
+ } returns AuthData(email = "anon@example.com", isAnonymous = true)
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { appInstallAgeLimitGate.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+ justRun { InstallWorkManager.enqueueWork(any(), any(), any()) }
+
+ val result = coordinator.enqueue(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.any {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify { appManagerWrapper.updateAwaiting(appInstall) }
+ verify { InstallWorkManager.enqueueWork(context, appInstall, false) }
+ } finally {
+ unmockkObject(InstallWorkManager)
+ }
+ }
+
+ @Test
+ fun canEnqueue_handlesFreeAppNotPurchasedAsRestricted() = runTest {
+ val appInstall = createNativeInstall(isFree = true)
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws InternalException.AppNotPurchased()
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppRestrictedOrUnavailable })
+ coVerify { appManager.addDownload(appInstall) }
+ coVerify { appManager.updateUnavailable(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_handlesPaidAppNotPurchasedAsPurchaseNeeded() = runTest {
+ val appInstall = createNativeInstall(isFree = false)
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws InternalException.AppNotPurchased()
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) }
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppPurchaseEvent })
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsHttpError() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws GplayHttpRequestException(403, "forbidden")
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent })
+ coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsIllegalState() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws IllegalStateException("boom")
+
+ val result = coordinator.canEnqueue(appInstall)
+
+ assertFalse(result)
+ 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/AppInstallWorkRunnerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bc446e8582e2020d63db02e44b4ccce0a1a00539
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallWorkRunnerTest.kt
@@ -0,0 +1,209 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.fdroid.FDroidRepository
+import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.download.DownloadManagerUtils
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.workmanager.AppInstallWorkRunner
+import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler
+import foundation.e.apps.util.MainCoroutineRule
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppInstallWorkRunnerTest {
+ @Rule
+ @JvmField
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ var mainCoroutineRule = MainCoroutineRule()
+
+ private lateinit var fakeFusedDownloadDAO: FakeAppInstallDAO
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var fakeFusedManagerRepository: FakeAppManagerWrapper
+ private lateinit var downloadManagerUtils: DownloadManagerUtils
+ private lateinit var appUpdateCompletionHandler: AppUpdateCompletionHandler
+ private lateinit var workRunner: AppInstallWorkRunner
+ private lateinit var context: Context
+
+ @Mock
+ private lateinit var fakeFusedManager: AppManager
+
+ @Mock
+ private lateinit var fakeFDroidRepository: FDroidRepository
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ context = mockk(relaxed = true)
+ fakeFusedDownloadDAO = FakeAppInstallDAO()
+ appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO)
+ fakeFusedManagerRepository =
+ FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository)
+ downloadManagerUtils = mockk(relaxed = true)
+ appUpdateCompletionHandler = mockk(relaxed = true)
+ workRunner = AppInstallWorkRunner(
+ appInstallRepository,
+ fakeFusedManagerRepository,
+ downloadManagerUtils,
+ appUpdateCompletionHandler
+ )
+ }
+
+ @Test
+ fun processInstall_completesNormalFlow() = runTest {
+ val fusedDownload = initTest()
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertTrue(finalFusedDownload == null)
+ }
+
+ @Test
+ fun processInstall_keepsBlockedDownloadUntouched() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.status = Status.BLOCKED
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.BLOCKED, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_marksDownloadedFilesAsInstalling() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertTrue(finalFusedDownload == null)
+ }
+
+ @Test
+ fun processInstall_reportsInvalidPackageAsInstallationIssue() = runTest {
+ val fusedDownload = initTest(packageName = "")
+ fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_reportsMissingDownloadUrlsAsInstallationIssue() = runTest {
+ val fusedDownload = initTest(downloadUrlList = mutableListOf())
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_returnsSuccessWhenInternalExceptionOccurs() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.forceCrash = true
+
+ val result = workRunner.processInstall(fusedDownload.id, false) {
+ // _ignored_
+ }
+ val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id)
+
+ assertTrue(result.isSuccess)
+ assertEquals(ResultStatus.OK, result.getOrNull())
+ assertTrue(finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE)
+ }
+
+ @Test
+ fun processInstall_reportsDownloadFailure() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.willDownloadFail = true
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_reportsInstallFailure() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.willInstallFail = true
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_updatesDownloadManagerStateForDownloadingItems() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.status = Status.DOWNLOADING
+ fusedDownload.downloadURLList = mutableListOf()
+ fusedDownload.downloadIdMap = mutableMapOf(231L to false, 232L to false)
+
+ workRunner.processInstall(fusedDownload.id, false) {
+ // _ignored_
+ }
+
+ verify { downloadManagerUtils.updateDownloadStatus(231L) }
+ verify { downloadManagerUtils.updateDownloadStatus(232L) }
+ }
+
+ private suspend fun initTest(
+ packageName: String? = null,
+ downloadUrlList: MutableList? = null
+ ): AppInstall {
+ val fusedDownload = createFusedDownload(packageName, downloadUrlList)
+ fakeFusedDownloadDAO.addDownload(fusedDownload)
+ return fusedDownload
+ }
+
+ private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? {
+ workRunner.processInstall(appInstall.id, false) {
+ // _ignored_
+ }
+ return fakeFusedDownloadDAO.getDownloadById(appInstall.id)
+ }
+
+ private fun createFusedDownload(
+ packageName: String? = null,
+ downloadUrlList: MutableList? = null
+ ) = AppInstall(
+ id = "121",
+ status = Status.AWAITING,
+ downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"),
+ packageName = packageName ?: "com.unit.test"
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9bb3a2d46c20e4ef2fdfc6fbb751bd717d43757f
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppUpdateCompletionHandlerTest.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.content.Context
+import com.aurora.gplayapi.data.models.AuthData
+import foundation.e.apps.R
+import foundation.e.apps.data.enums.Status
+import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.workmanager.AppUpdateCompletionHandler
+import foundation.e.apps.data.install.wrapper.UpdatesNotificationSender
+import foundation.e.apps.data.install.wrapper.UpdatesTracker
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.util.MainCoroutineRule
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.Locale
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppUpdateCompletionHandlerTest {
+
+ @get:Rule
+ var mainCoroutineRule = MainCoroutineRule()
+
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var playStoreAuthStore: PlayStoreAuthStore
+ private lateinit var updatesTracker: UpdatesTracker
+ private lateinit var updatesNotificationSender: UpdatesNotificationSender
+ private lateinit var context: Context
+ private lateinit var handler: AppUpdateCompletionHandler
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ appInstallRepository = AppInstallRepository(FakeAppInstallDAO())
+ appManagerWrapper = mockk(relaxed = true)
+ playStoreAuthStore = mockk(relaxed = true)
+ updatesTracker = mockk(relaxed = true)
+ updatesNotificationSender = mockk(relaxed = true)
+ coEvery { playStoreAuthStore.awaitAuthData() } returns null
+ handler = AppUpdateCompletionHandler(
+ context,
+ appInstallRepository,
+ appManagerWrapper,
+ playStoreAuthStore,
+ updatesTracker,
+ updatesNotificationSender
+ )
+ }
+
+ @Test
+ fun onInstallFinished_doesNothingWhenNotUpdateWork() = runTest {
+ handler.onInstallFinished(AppInstall(id = "123", packageName = "com.example.app"), false)
+
+ verify(exactly = 0) { appManagerWrapper.getFusedDownloadPackageStatus(any()) }
+ verify(exactly = 0) { updatesTracker.addSuccessfullyUpdatedApp(any()) }
+ verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) }
+ }
+
+ @Test
+ fun onInstallFinished_tracksInstalledUpdates() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns false
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesTracker.addSuccessfullyUpdatedApp(appInstall) }
+ verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) }
+ }
+
+ @Test
+ fun onInstallFinished_sendsNotificationWhenUpdateBatchCompletes() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 2
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesNotificationSender.showNotification("Update", "Updated message") }
+ verify { updatesTracker.clearSuccessfullyUpdatedApps() }
+ }
+
+ @Test
+ fun onInstallFinished_ignoresIssueAndPurchaseNeededStatusesForCompletion() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ appInstallRepository.addDownload(
+ AppInstall(
+ id = "issue",
+ status = Status.INSTALLATION_ISSUE,
+ packageName = "com.example.issue"
+ )
+ )
+ appInstallRepository.addDownload(
+ AppInstall(
+ id = "purchase",
+ status = Status.PURCHASE_NEEDED,
+ packageName = "com.example.purchase"
+ )
+ )
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 1
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesNotificationSender.showNotification("Update", "Updated message") }
+ }
+
+ @Test
+ fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 1
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesTracker.clearSuccessfullyUpdatedApps() }
+ }
+
+ private suspend fun stubUpdateNotificationContext() {
+ val authData = AuthData(email = "user@example.com", isAnonymous = false).apply {
+ locale = Locale.US
+ }
+ coEvery { playStoreAuthStore.awaitAuthData() } returns authData
+ every { context.getString(R.string.update) } returns "Update"
+ every {
+ context.getString(
+ R.string.message_last_update_triggered,
+ any(),
+ any()
+ )
+ } returns "Updated message"
+ }
+}
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)
+ }
+ }
+}