diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 9b4ff20b2602a7462e155a66c3e1f7f5af58d4de..9f5e411a678e1ec68234dd3907061797a70312a3 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -16,7 +16,6 @@
LongParameterList:MainActivityViewModel.kt$MainActivityViewModel$( private val appLoungeDataStore: AppLoungeDataStore, private val applicationRepository: ApplicationRepository, private val appManagerWrapper: AppManagerWrapper, private val appLoungePackageManager: AppLoungePackageManager, private val pwaManager: PwaManager, private val blockedAppRepository: BlockedAppRepository, private val gPlayContentRatingRepository: GPlayContentRatingRepository, private val fDroidAntiFeatureRepository: FDroidAntiFeatureRepository, private val appInstallProcessor: AppInstallProcessor, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, private val reportFaultyTokenUseCase: ReportFaultyTokenUseCase, )
LongParameterList:UpdatesManagerImpl.kt$UpdatesManagerImpl$( @ApplicationContext private val context: Context, private val appLoungePackageManager: AppLoungePackageManager, private val applicationRepository: ApplicationRepository, private val faultyAppRepository: FaultyAppRepository, private val appLoungePreference: AppLoungePreference, private val fDroidRepository: FDroidRepository, private val blockedAppRepository: BlockedAppRepository, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, )
LongParameterList:UpdatesWorker.kt$UpdatesWorker$( @Assisted private val context: Context, @Assisted private val params: WorkerParameters, private val updatesManagerRepository: UpdatesManagerRepository, private val appLoungeDataStore: AppLoungeDataStore, private val authenticatorRepository: AuthenticatorRepository, private val appInstallProcessor: AppInstallProcessor, private val blockedAppRepository: BlockedAppRepository, private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, )
- NoUnusedImports:AppInstallComponents.kt$foundation.e.apps.data.install.AppInstallComponents.kt
ProtectedMemberInFinalClass:ApplicationListFragment.kt$ApplicationListFragment$// protected to avoid SyntheticAccessor protected val args: ApplicationListFragmentArgs by navArgs()
ProtectedMemberInFinalClass:ApplicationListFragment.kt$ApplicationListFragment$// protected to avoid SyntheticAccessor protected val viewModel: ApplicationListViewModel by viewModels()
ProtectedMemberInFinalClass:GoogleSignInFragment.kt$GoogleSignInFragment$// protected to avoid SyntheticAccessor protected val viewModel: LoginViewModel by lazy { ViewModelProvider(requireActivity())[LoginViewModel::class.java] }
diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt
index 5a96dbf71ab90fc77df771f29dbefdcf7a22adea..ed694ba5b1cccbb405a7fc93eed60a109d64127a 100644
--- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt
+++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt
@@ -29,11 +29,11 @@ import dagger.hilt.android.HiltAndroidApp
import foundation.e.apps.data.Constants.TAG_APP_INSTALL_STATE
import foundation.e.apps.data.Constants.TAG_AUTHDATA_DUMP
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
-import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PkgManagerBR
import foundation.e.apps.data.install.updates.UpdatesWorkManager
import foundation.e.apps.data.install.workmanager.InstallOrchestrator
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.data.system.CustomUncaughtExceptionHandler
import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.domain.preferences.SessionRepository
@@ -48,7 +48,6 @@ import timber.log.Timber
import timber.log.Timber.Forest.plant
import java.util.concurrent.Executors
import javax.inject.Inject
-
@HiltAndroidApp
@DelicateCoroutinesApi
class AppLoungeApplication : Application(), Configuration.Provider {
@@ -60,7 +59,7 @@ class AppLoungeApplication : Application(), Configuration.Provider {
lateinit var workerFactory: HiltWorkerFactory
@Inject
- lateinit var appInstallDao: AppInstallDAO
+ lateinit var appInstallRepository: AppInstallRepository
@Inject
lateinit var uncaughtExceptionHandler: CustomUncaughtExceptionHandler
@@ -140,7 +139,7 @@ class AppLoungeApplication : Application(), Configuration.Provider {
private fun isRunningUnderRobolectric(): Boolean = Build.FINGERPRINT == "robolectric"
private fun removeStalledInstallationFromDb() = coroutineScope.launch {
- val existingInstallations = appInstallDao.getItemInInstallation().toMutableList()
+ val existingInstallations = appInstallRepository.getItemInInstallation().toMutableList()
if (existingInstallations.isEmpty()) {
return@launch
}
@@ -162,7 +161,7 @@ class AppLoungeApplication : Application(), Configuration.Provider {
Timber.d("removing (${appInstall.packageName}) : (${appInstall.id}) from db")
appInstall.status = Status.INSTALLATION_ISSUE
- appInstallDao.deleteDownload(appInstall)
+ appInstallRepository.deleteDownload(appInstall)
}
}
diff --git a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt
index 5debc9706d04cf579fbb985710cbbe44e1236219..5c6d31168f389f7f31056d7095b16e62b1c60115 100644
--- a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt
+++ b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt
@@ -33,7 +33,7 @@ import foundation.e.apps.data.enums.FilterLevel
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.handleNetworkResult
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
diff --git a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt
index 5b0f58d66192eb5ef8d2422f7b970cb9ec1d4a7b..e99c0cd03dea86e7394eefc53006b84507232631 100644
--- a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt
+++ b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt
@@ -18,7 +18,7 @@
package foundation.e.apps.data.application
import foundation.e.apps.data.application.data.Application
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
object UpdatesDao {
private val _appsAwaitingForUpdate: MutableList = mutableListOf()
diff --git a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApi.kt b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApi.kt
index b29dbb5f42bb82ec363fc2260781b382de59a510..cb85ad2ee9927fd39659b732c26b3249b4679310 100644
--- a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApi.kt
+++ b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApi.kt
@@ -20,7 +20,7 @@ package foundation.e.apps.data.application.downloadInfo
import foundation.e.apps.data.cleanapk.data.download.Download
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import retrofit2.Response
interface DownloadInfoApi {
diff --git a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt
index 47a1b720c9a07776de214415ef2fd396052b394b..e79e6daf4356531cdb75f50c3ea86e3b9e1e6de1 100644
--- a/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt
+++ b/app/src/main/java/foundation/e/apps/data/application/downloadInfo/DownloadInfoApiImpl.kt
@@ -22,7 +22,7 @@ import foundation.e.apps.data.AppSourcesContainer
import foundation.e.apps.data.cleanapk.CleanApkDownloadInfoFetcher
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.handleNetworkResult
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import javax.inject.Inject
class DownloadInfoApiImpl @Inject constructor(
diff --git a/app/src/main/java/foundation/e/apps/data/database/AppDatabase.kt b/app/src/main/java/foundation/e/apps/data/database/AppDatabase.kt
index eb7e4fa66cec7b3cdf74f901c8e2be3d8b4e450b..0b4f88721f3f23afbb9f3566870868aa746f2515 100644
--- a/app/src/main/java/foundation/e/apps/data/database/AppDatabase.kt
+++ b/app/src/main/java/foundation/e/apps/data/database/AppDatabase.kt
@@ -7,18 +7,17 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
-import foundation.e.apps.data.database.install.AppInstallConverter
import foundation.e.apps.data.exodus.Tracker
import foundation.e.apps.data.exodus.TrackerDao
import foundation.e.apps.data.faultyApps.FaultyApp
import foundation.e.apps.data.faultyApps.FaultyAppDao
import foundation.e.apps.data.fdroid.FdroidDao
import foundation.e.apps.data.fdroid.models.FdroidEntity
+import foundation.e.apps.data.installation.local.AppInstallConverter
import foundation.e.apps.data.parentalcontrol.ContentRatingDao
import foundation.e.apps.data.parentalcontrol.ContentRatingEntity
import foundation.e.apps.data.parentalcontrol.FDroidNsfwApp
import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup
-
@Database(
entities = [
Tracker::class,
diff --git a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt b/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt
deleted file mode 100644
index 011d4af7cdf127c3800263c102a9fdaf98fd3f3d..0000000000000000000000000000000000000000
--- a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallDatabase.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package foundation.e.apps.data.database.install
-
-import android.content.Context
-import androidx.room.Database
-import androidx.room.Room
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverters
-import foundation.e.apps.data.database.AppDatabase
-import foundation.e.apps.data.install.AppInstallDAO
-import foundation.e.apps.data.install.models.AppInstall
-
-@Database(entities = [AppInstall::class], version = 6, exportSchema = false)
-@TypeConverters(AppInstallConverter::class)
-abstract class AppInstallDatabase : RoomDatabase() {
- abstract fun fusedDownloadDao(): AppInstallDAO
-
- companion object {
- private lateinit var INSTANCE: AppInstallDatabase
- private const val DATABASE_NAME = "fused_database"
-
- fun getInstance(context: Context): AppInstallDatabase {
- if (!Companion::INSTANCE.isInitialized) {
- synchronized(AppDatabase::class) {
- INSTANCE =
- Room.databaseBuilder(context, AppInstallDatabase::class.java, DATABASE_NAME)
- .fallbackToDestructiveMigration()
- .build()
- }
- }
- return INSTANCE
- }
- }
-}
diff --git a/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.kt b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d95af38ede6cf3e3a61c92d61895dd8b3bbbcd8
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/di/bindings/AppInstallationModule.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.AppManagerWrapper
+import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler
+import foundation.e.apps.data.install.download.DownloadManagerUtils
+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.ParentalControlAuthGatewayImpl
+import foundation.e.apps.data.install.wrapper.StorageSpaceCheckerImpl
+import foundation.e.apps.data.install.wrapper.UpdatesNotificationSenderImpl
+import foundation.e.apps.data.install.wrapper.UpdatesTrackerImpl
+import foundation.e.apps.data.installation.port.InstallationAppManager
+import foundation.e.apps.data.installation.port.InstallationCompletionNotifier
+import foundation.e.apps.data.installation.port.InstallationDownloadStatusUpdater
+import foundation.e.apps.data.installation.port.NetworkStatusChecker
+import foundation.e.apps.data.installation.port.ParentalControlAuthGateway
+import foundation.e.apps.data.installation.port.StorageSpaceChecker
+import foundation.e.apps.data.installation.port.UpdatesNotificationSender
+import foundation.e.apps.data.installation.port.UpdatesTracker
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface AppInstallationModule {
+
+ @Binds
+ @Singleton
+ fun bindAppEventDispatcher(dispatcher: DefaultAppEventDispatcher): AppEventDispatcher
+
+ @Binds
+ @Singleton
+ fun bindStorageSpaceChecker(checker: StorageSpaceCheckerImpl): StorageSpaceChecker
+
+ @Binds
+ @Singleton
+ fun bindParentalControlAuthGateway(gateway: ParentalControlAuthGatewayImpl): ParentalControlAuthGateway
+
+ @Binds
+ @Singleton
+ fun bindUpdatesTracker(tracker: UpdatesTrackerImpl): UpdatesTracker
+
+ @Binds
+ @Singleton
+ fun bindUpdatesNotificationSender(sender: UpdatesNotificationSenderImpl): UpdatesNotificationSender
+
+ @Binds
+ @Singleton
+ fun bindNetworkStatusChecker(checker: DeviceNetworkStatusChecker): NetworkStatusChecker
+
+ @Binds
+ @Singleton
+ fun bindInstallationAppManager(appManagerWrapper: AppManagerWrapper): InstallationAppManager
+
+ @Binds
+ @Singleton
+ fun bindInstallationDownloadStatusUpdater(
+ downloadManagerUtils: DownloadManagerUtils
+ ): InstallationDownloadStatusUpdater
+
+ @Binds
+ @Singleton
+ fun bindInstallationCompletionNotifier(handler: InstallationCompletionHandler): InstallationCompletionNotifier
+}
diff --git a/app/src/main/java/foundation/e/apps/data/di/db/DatabaseModule.kt b/app/src/main/java/foundation/e/apps/data/di/db/DatabaseModule.kt
index 615edbd93ff7573cb073740d3029daba6d81d263..0404f691dbcf5e570d383d43c5fd5648dc23cbc9 100644
--- a/app/src/main/java/foundation/e/apps/data/di/db/DatabaseModule.kt
+++ b/app/src/main/java/foundation/e/apps/data/di/db/DatabaseModule.kt
@@ -1,27 +1 @@
package foundation.e.apps.data.di.db
-
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import foundation.e.apps.data.database.install.AppInstallDatabase
-import foundation.e.apps.data.install.AppInstallDAO
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object DatabaseModule {
- @Singleton
- @Provides
- fun provideDatabaseInstance(@ApplicationContext context: Context): AppInstallDatabase {
- return AppInstallDatabase.getInstance(context)
- }
-
- @Singleton
- @Provides
- fun provideFusedDaoInstance(appInstallDatabase: AppInstallDatabase): AppInstallDAO {
- return appInstallDatabase.fusedDownloadDao()
- }
-}
diff --git a/app/src/main/java/foundation/e/apps/data/event/AppEvent.kt b/app/src/main/java/foundation/e/apps/data/event/AppEvent.kt
index b12a12ea81131fdc98c0cfbf819508621060be88..041eb165cfb8f9826dbcfb7cdab88e26fc0df774 100644
--- a/app/src/main/java/foundation/e/apps/data/event/AppEvent.kt
+++ b/app/src/main/java/foundation/e/apps/data/event/AppEvent.kt
@@ -22,7 +22,7 @@ package foundation.e.apps.data.event
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.enums.ResultStatus
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.model.User
import kotlinx.coroutines.CompletableDeferred
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallComponents.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallComponents.kt
deleted file mode 100644
index 4133ec4594e6f27113d0c3de21c907cf6d52cda0..0000000000000000000000000000000000000000
--- a/app/src/main/java/foundation/e/apps/data/install/AppInstallComponents.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright MURENA SAS 2024
- * Apps Quickly and easily install Android apps onto your device!
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- *
- */
-
-package foundation.e.apps.data.install
-
-import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.AppManagerWrapper
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class AppInstallComponents @Inject constructor(
- val appInstallRepository: AppInstallRepository,
- val appManagerWrapper: AppManagerWrapper
-)
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallIconUrlBuilder.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallIconUrlBuilder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fe26932619f5c1565cfc54a98de76494e541ae38
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/AppInstallIconUrlBuilder.kt
@@ -0,0 +1,15 @@
+package foundation.e.apps.data.install
+
+import foundation.e.apps.data.cleanapk.CleanApkRetrofit
+import foundation.e.apps.data.installation.model.InstallationSource
+import javax.inject.Inject
+
+class AppInstallIconUrlBuilder @Inject constructor() {
+ fun build(source: InstallationSource, iconImageUrl: String): String {
+ return if (source == InstallationSource.PLAY_STORE || source == InstallationSource.PWA) {
+ "${CleanApkRetrofit.ASSET_URL}$iconImageUrl"
+ } else {
+ iconImageUrl
+ }
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt b/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bbe5af1b67cfdc0ddb32e97a13094c5816b36ec0
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/AppInstallMappings.kt
@@ -0,0 +1,38 @@
+package foundation.e.apps.data.install
+
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
+
+fun Source.toInstallationSource(): InstallationSource {
+ return when (this) {
+ Source.OPEN_SOURCE -> InstallationSource.OPEN_SOURCE
+ Source.PWA -> InstallationSource.PWA
+ Source.SYSTEM_APP -> InstallationSource.SYSTEM_APP
+ Source.PLAY_STORE -> InstallationSource.PLAY_STORE
+ }
+}
+
+fun Type.toInstallationType(): InstallationType {
+ return when (this) {
+ Type.NATIVE -> InstallationType.NATIVE
+ Type.PWA -> InstallationType.PWA
+ }
+}
+
+fun InstallationSource.toAppSource(): Source {
+ return when (this) {
+ InstallationSource.OPEN_SOURCE -> Source.OPEN_SOURCE
+ InstallationSource.PWA -> Source.PWA
+ InstallationSource.SYSTEM_APP -> Source.SYSTEM_APP
+ InstallationSource.PLAY_STORE -> Source.PLAY_STORE
+ }
+}
+
+fun InstallationType.toAppType(): Type {
+ return when (this) {
+ InstallationType.NATIVE -> Type.NATIVE
+ InstallationType.PWA -> Type.PWA
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManager.kt b/app/src/main/java/foundation/e/apps/data/install/AppManager.kt
index d09415b790b4e737b65bb55505240da7faecc825..b5267bfc9b37702007e636226e9bf0cc2505ea23 100644
--- a/app/src/main/java/foundation/e/apps/data/install/AppManager.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/AppManager.kt
@@ -19,7 +19,7 @@
package foundation.e.apps.data.install
import androidx.lifecycle.LiveData
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.model.install.Status
import java.io.File
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt
index b7509d197cbff227ef248c3090a0cb1fce047636..ebf2b6255fd423ce750c49118dc2d94572bb662f 100644
--- a/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerImpl.kt
@@ -28,11 +28,12 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
-import foundation.e.apps.data.enums.Type
import foundation.e.apps.data.install.download.data.DownloadProgressLD
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationType
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.data.parentalcontrol.ContentRatingDao
import foundation.e.apps.data.parentalcontrol.ContentRatingEntity
import foundation.e.apps.domain.model.install.Status
@@ -46,7 +47,6 @@ import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import com.aurora.gplayapi.data.models.PlayFile as AuroraFile
-
@Singleton
class AppManagerImpl @Inject constructor(
@Named("cacheDir") private val cacheDir: String,
@@ -127,8 +127,8 @@ class AppManagerImpl @Inject constructor(
override suspend fun downloadApp(appInstall: AppInstall, isUpdate: Boolean) {
mutex.withLock {
when (appInstall.type) {
- Type.NATIVE -> downloadNativeApp(appInstall, isUpdate)
- Type.PWA -> pwaManager.installPWAApp(appInstall)
+ InstallationType.NATIVE -> downloadNativeApp(appInstall, isUpdate)
+ InstallationType.PWA -> pwaManager.installPWAApp(appInstall)
}
}
}
@@ -136,7 +136,7 @@ class AppManagerImpl @Inject constructor(
override suspend fun installApp(appInstall: AppInstall) {
val list = mutableListOf()
when (appInstall.type) {
- Type.NATIVE -> {
+ InstallationType.NATIVE -> {
val parentPathFile = File("$cacheDir/${appInstall.packageName}")
parentPathFile.listFiles()?.let { list.addAll(it) }
list.sort()
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt
index 87f9bc4ea2a374be41a8fda9fafc3dc851f38ca1..27b6c9f3e781f44bf71be13d6a308756d1abe02f 100644
--- a/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/AppManagerWrapper.kt
@@ -25,27 +25,29 @@ import foundation.e.apps.OpenForTesting
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.fdroid.FDroidRepository
import foundation.e.apps.data.install.download.data.DownloadProgress
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.workmanager.InstallWorkManager
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.InstallationAppManager
import foundation.e.apps.domain.model.install.Status
import javax.inject.Inject
import javax.inject.Singleton
private const val PERCENTAGE_MULTIPLIER = 100
+@Suppress("TooManyFunctions")
@Singleton
@OpenForTesting
class AppManagerWrapper @Inject constructor(
@ApplicationContext private val context: Context,
private val appManager: AppManager,
private val fDroidRepository: FDroidRepository
-) {
+) : InstallationAppManager {
fun createNotificationChannels() {
return appManager.createNotificationChannels()
}
- suspend fun downloadApp(appInstall: AppInstall, isUpdate: Boolean = false) {
+ override suspend fun downloadApp(appInstall: AppInstall, isUpdate: Boolean) {
return appManager.downloadApp(appInstall, isUpdate)
}
@@ -100,15 +102,19 @@ class AppManagerWrapper @Inject constructor(
return appManager.getFusedDownload(downloadId, packageName)
}
- suspend fun updateDownloadStatus(appInstall: AppInstall, status: Status) {
+ override suspend fun updateDownloadStatus(appInstall: AppInstall, status: Status) {
return appManager.updateDownloadStatus(appInstall, status)
}
+ override suspend fun cancelDownload(appInstall: AppInstall) {
+ return appManager.cancelDownload(appInstall, "")
+ }
+
suspend fun cancelDownload(appInstall: AppInstall, packageName: String = "") {
return appManager.cancelDownload(appInstall, packageName)
}
- suspend fun installationIssue(appInstall: AppInstall) {
+ override suspend fun installationIssue(appInstall: AppInstall) {
return appManager.reportInstallationIssue(appInstall)
}
@@ -124,7 +130,7 @@ class AppManagerWrapper @Inject constructor(
appManager.updateAppInstall(appInstall)
}
- fun validateFusedDownload(appInstall: AppInstall) =
+ override fun validateFusedDownload(appInstall: AppInstall) =
appInstall.packageName.isNotEmpty() && appInstall.downloadURLList.isNotEmpty()
suspend fun calculateProgress(
@@ -212,7 +218,7 @@ class AppManagerWrapper @Inject constructor(
)
}
- fun isFusedDownloadInstalled(appInstall: AppInstall): Boolean {
+ override fun isFusedDownloadInstalled(appInstall: AppInstall): Boolean {
return appManager.isAppInstalled(appInstall)
}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8f6c329facb16243f903a8059899cd5dcc78b179
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/AppInstallationFacade.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core
+
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.core.InstallationProcessor
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationResult
+import foundation.e.apps.domain.model.install.Status
+import javax.inject.Inject
+
+class AppInstallationFacade @Inject constructor(
+ private val appManagerWrapper: AppManagerWrapper,
+ private val installationEnqueuer: InstallationEnqueuer,
+ private val installationProcessor: InstallationProcessor,
+ private val installationRequest: InstallationRequest,
+) {
+ /**
+ * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process.
+ * @param application represents the app info which will be installed
+ * @param isAnUpdate indicates the app is requested for update or not
+ *
+ */
+ suspend fun initAppInstall(
+ application: Application,
+ isAnUpdate: Boolean = false
+ ): Boolean {
+ val appInstall = installationRequest.create(application)
+
+ val isUpdate = isAnUpdate ||
+ application.status == Status.UPDATABLE ||
+ appManagerWrapper.isFusedDownloadInstalled(appInstall)
+
+ return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp)
+ }
+
+ /**
+ * Enqueues [AppInstall] into WorkManager to run app install process. Before enqueuing,
+ * It validates some corner cases
+ * @param appInstall represents the app downloading and installing related info, example- Installing Status,
+ * Url of the APK,OBB files are needed to be downloaded and installed etc.
+ * @param isAnUpdate indicates the app is requested for update or not
+ */
+ suspend fun enqueueFusedDownload(
+ appInstall: AppInstall,
+ isAnUpdate: Boolean = false,
+ isSystemApp: Boolean = false
+ ): Boolean {
+ return installationEnqueuer.enqueue(appInstall, isAnUpdate, isSystemApp)
+ }
+
+ suspend fun processInstall(
+ fusedDownloadId: String,
+ isItUpdateWork: Boolean,
+ runInForeground: (suspend (String) -> Unit)
+ ): Result {
+ return installationProcessor.processInstall(fusedDownloadId, isItUpdateWork, runInForeground)
+ .map { installationResult ->
+ when (installationResult) {
+ InstallationResult.OK -> ResultStatus.OK
+ InstallationResult.TIMEOUT -> ResultStatus.TIMEOUT
+ InstallationResult.UNKNOWN -> ResultStatus.UNKNOWN
+ InstallationResult.RETRY -> ResultStatus.RETRY
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1a4f75daa0351afece5d85b941bec100cd99fb14
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationEnqueuer.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.R
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.core.helper.PreEnqueueChecker
+import foundation.e.apps.data.install.workmanager.InstallWorkManager
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.domain.model.User
+import foundation.e.apps.domain.preferences.SessionRepository
+import kotlinx.coroutines.CancellationException
+import timber.log.Timber
+import javax.inject.Inject
+class InstallationEnqueuer @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val preEnqueueChecker: PreEnqueueChecker,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val sessionRepository: SessionRepository,
+ private val playStoreAuthStore: PlayStoreAuthStore,
+ private val appEventDispatcher: AppEventDispatcher,
+) {
+ suspend fun enqueue(
+ appInstall: AppInstall,
+ isAnUpdate: Boolean = false,
+ isSystemApp: Boolean = false
+ ): Boolean {
+ return runCatching {
+ when {
+ isEnqueueingPaidAppInAnonymouseMode(appInstall, isSystemApp) -> {
+ dispatchAnonymousPaidAppWarning()
+ false
+ }
+
+ canEnqueue(appInstall, isAnUpdate) -> {
+ appManagerWrapper.updateAwaiting(appInstall)
+ // Enqueueing installation work is managed by InstallOrchestrator#observeDownloads().
+ // This method only handles update work.
+ if (isAnUpdate) {
+ enqueueUpdate(appInstall)
+ }
+ true
+ }
+
+ else -> {
+ Timber.w("Can't enqueue ${appInstall.name}/${appInstall.packageName} for installation/update.")
+ false
+ }
+ }
+ }.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
+ }
+ }
+ }
+
+ private suspend fun isEnqueueingPaidAppInAnonymouseMode(
+ appInstall: AppInstall,
+ isSystemApp: Boolean
+ ): Boolean {
+ val user = sessionRepository.awaitUser()
+ return when {
+ isSystemApp -> false
+ user == User.ANONYMOUS -> {
+ val authData = playStoreAuthStore.awaitAuthData()
+ !appInstall.isFree && authData?.isAnonymous == true
+ }
+
+ else -> false
+ }
+ }
+
+ private fun enqueueUpdate(appInstall: AppInstall) {
+ val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName)
+ InstallWorkManager.enqueueWork(context, appInstall, true)
+ Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName")
+ }
+
+ suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean {
+ return preEnqueueChecker.canEnqueue(appInstall, isAnUpdate)
+ }
+
+ private suspend fun dispatchAnonymousPaidAppWarning() {
+ appEventDispatcher.dispatch(
+ AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message)
+ )
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ca0d0044baaec73976d1b6de6aede9c1e0bb4028
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/InstallationRequest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core
+
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.install.toInstallationSource
+import foundation.e.apps.data.install.toInstallationType
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationType
+import javax.inject.Inject
+class InstallationRequest @Inject constructor() {
+ fun create(application: Application): AppInstall {
+ val appInstall = AppInstall(
+ application._id,
+ application.source.toInstallationSource(),
+ application.status,
+ application.name,
+ application.package_name,
+ mutableListOf(),
+ mutableMapOf(),
+ application.status,
+ application.type.toInstallationType(),
+ application.icon_image_path,
+ application.latest_version_code,
+ application.offer_type,
+ application.isFree,
+ application.originalSize
+ ).also {
+ it.contentRating = application.contentRating
+ }
+
+ if (appInstall.type == InstallationType.PWA || application.source == Source.SYSTEM_APP) {
+ appInstall.downloadURLList = mutableListOf(application.url)
+ }
+
+ return appInstall
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/AgeLimiter.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/AgeLimiter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..909bcdba1cda6b5478e1438c0fc37d779cab3e65
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/AgeLimiter.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.core.helper
+
+import foundation.e.apps.R
+import foundation.e.apps.data.ResultSupreme
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.ParentalControlAuthGateway
+import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
+import foundation.e.apps.domain.model.ContentRatingValidity
+import kotlinx.coroutines.CompletableDeferred
+import javax.inject.Inject
+class AgeLimiter @Inject constructor(
+ private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val parentalControlAuthGateway: ParentalControlAuthGateway,
+) {
+ suspend fun allow(appInstall: AppInstall): Boolean {
+ val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall)
+ val isAllowed = when {
+ ageLimitValidationResult.data?.isValid == true -> true
+ ageLimitValidationResult.isSuccess() -> handleSuccessfulValidation(
+ ageLimitValidationResult,
+ appInstall.name
+ )
+
+ else -> {
+ appEventDispatcher.dispatch(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc))
+ false
+ }
+ }
+
+ if (!isAllowed) {
+ appManagerWrapper.cancelDownload(appInstall)
+ }
+
+ return isAllowed
+ }
+
+ private suspend fun handleSuccessfulValidation(
+ ageLimitValidationResult: ResultSupreme,
+ appName: String
+ ): Boolean {
+ awaitInvokeAgeLimitEvent(appName)
+ if (ageLimitValidationResult.data?.requestPin == true &&
+ parentalControlAuthGateway.awaitAuthentication()
+ ) {
+ ageLimitValidationResult.setData(ContentRatingValidity(true))
+ }
+ return ageLimitValidationResult.data?.isValid == true
+ }
+
+ private suspend fun awaitInvokeAgeLimitEvent(type: String) {
+ val deferred = CompletableDeferred()
+ appEventDispatcher.dispatch(AppEvent.AgeLimitRestrictionEvent(type, deferred))
+ deferred.await()
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2a6c92347bc7687bb6a733b1f5f5dcf5388d966f
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/DevicePreconditions.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core.helper
+
+import foundation.e.apps.R
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.notification.StorageNotificationManager
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.NetworkStatusChecker
+import foundation.e.apps.data.installation.port.StorageSpaceChecker
+import timber.log.Timber
+import javax.inject.Inject
+class DevicePreconditions @Inject constructor(
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val storageNotificationManager: StorageNotificationManager,
+ private val storageSpaceChecker: StorageSpaceChecker,
+ private val networkStatusChecker: NetworkStatusChecker,
+) {
+ suspend fun canProceed(appInstall: AppInstall): Boolean {
+ val hasNetwork = hasNetworkConnection(appInstall)
+ return hasNetwork && hasStorageSpace(appInstall)
+ }
+
+ private suspend fun hasNetworkConnection(appInstall: AppInstall): Boolean {
+ val hasNetwork = networkStatusChecker.isNetworkAvailable()
+ if (!hasNetwork) {
+ appManagerWrapper.installationIssue(appInstall)
+ appEventDispatcher.dispatch(AppEvent.NoInternetEvent(false))
+ }
+ return hasNetwork
+ }
+
+ private suspend fun hasStorageSpace(appInstall: AppInstall): Boolean {
+ val missingStorage = storageSpaceChecker.spaceMissing(appInstall)
+ if (missingStorage > 0) {
+ Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}")
+ storageNotificationManager.showNotEnoughSpaceNotification(appInstall)
+ appManagerWrapper.installationIssue(appInstall)
+ appEventDispatcher.dispatch(AppEvent.ErrorMessageEvent(R.string.not_enough_storage))
+ }
+ return missingStorage <= 0
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/DownloadUrlRefresher.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/DownloadUrlRefresher.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5a84f53b5a9994e08bb0e7b1b8c969948d84b3fe
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/DownloadUrlRefresher.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core.helper
+
+import com.aurora.gplayapi.exceptions.InternalException
+import foundation.e.apps.data.ResultSupreme
+import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.toAppSource
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
+import kotlinx.coroutines.CancellationException
+import timber.log.Timber
+import javax.inject.Inject
+class DownloadUrlRefresher @Inject constructor(
+ private val applicationRepository: ApplicationRepository,
+ private val appInstallRepository: AppInstallRepository,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val appEventDispatcher: AppEventDispatcher,
+ private val appManager: AppManager,
+) {
+ suspend fun updateDownloadUrls(appInstall: AppInstall, isAnUpdate: Boolean): Boolean {
+ return runCatching {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ appInstall.source.toAppSource(),
+ appInstall
+ )
+ }.fold(
+ onSuccess = { true },
+ onFailure = { throwable ->
+ handleUpdateDownloadFailure(
+ appInstall,
+ isAnUpdate,
+ throwable
+ )
+ }
+ )
+ }
+
+ private suspend fun handleUpdateDownloadFailure(
+ appInstall: AppInstall,
+ isAnUpdate: Boolean,
+ throwable: Throwable
+ ): Boolean {
+ return when (throwable) {
+ is CancellationException -> throw throwable
+ is InternalException.AppNotPurchased -> {
+ handleAppNotPurchased(appInstall)
+ false
+ }
+ is Exception -> {
+ val message = if (throwable is GplayHttpRequestException) {
+ "${appInstall.packageName} code: ${throwable.status} exception: ${throwable.message}"
+ } else {
+ "${appInstall.packageName} exception: ${throwable.message}"
+ }
+ Timber.e(throwable, "Updating download URLS failed for $message")
+ handleUpdateDownloadError(appInstall, isAnUpdate)
+ false
+ }
+
+ else -> throw throwable
+ }
+ }
+
+ private suspend fun handleAppNotPurchased(appInstall: AppInstall) {
+ if (appInstall.isFree) {
+ appEventDispatcher.dispatch(AppEvent.AppRestrictedOrUnavailable(appInstall))
+ appManager.addDownload(appInstall)
+ appManager.updateUnavailable(appInstall)
+ } else {
+ appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall)
+ appEventDispatcher.dispatch(AppEvent.AppPurchaseEvent(appInstall))
+ }
+ }
+
+ private suspend fun handleUpdateDownloadError(appInstall: AppInstall, isAnUpdate: Boolean) {
+ // Insert into DB to reflect error state on UI.
+ // For example, install button's label will change to Install -> Cancel -> Retry
+ if (appInstallRepository.getDownloadById(appInstall.id) == null) {
+ appInstallRepository.addDownload(appInstall)
+ }
+ appManagerWrapper.installationIssue(appInstall)
+
+ if (isAnUpdate) {
+ appEventDispatcher.dispatch(
+ AppEvent.UpdateEvent(
+ ResultSupreme.WorkError(
+ ResultStatus.UNKNOWN,
+ appInstall
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e1c421217c5027116a91f3b8df786fa1bd5d89f4
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/InstallationCompletionHandler.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core.helper
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.R
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.InstallationCompletionNotifier
+import foundation.e.apps.data.installation.port.UpdatesNotificationSender
+import foundation.e.apps.data.installation.port.UpdatesTracker
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.data.utils.getFormattedString
+import foundation.e.apps.domain.model.install.Status
+import java.text.NumberFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+class InstallationCompletionHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val appInstallRepository: AppInstallRepository,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val playStoreAuthStore: PlayStoreAuthStore,
+ private val updatesTracker: UpdatesTracker,
+ private val updatesNotificationSender: UpdatesNotificationSender,
+) : InstallationCompletionNotifier {
+ companion object {
+ private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm"
+ }
+
+ override suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean) {
+ if (!isUpdateWork) {
+ return
+ }
+
+ appInstall?.let {
+ val packageStatus = appManagerWrapper.getFusedDownloadPackageStatus(appInstall)
+
+ if (packageStatus == Status.INSTALLED) {
+ updatesTracker.addSuccessfullyUpdatedApp(it)
+ }
+
+ if (isUpdateCompleted()) {
+ showNotificationOnUpdateEnded()
+ updatesTracker.clearSuccessfullyUpdatedApps()
+ }
+ }
+ }
+
+ private suspend fun isUpdateCompleted(): Boolean {
+ val downloadListWithoutAnyIssue = appInstallRepository.getDownloadList().filter {
+ !listOf(Status.INSTALLATION_ISSUE, Status.PURCHASE_NEEDED).contains(it.status)
+ }
+
+ return updatesTracker.hasSuccessfulUpdatedApps() && downloadListWithoutAnyIssue.isEmpty()
+ }
+
+ private suspend fun showNotificationOnUpdateEnded() {
+ val locale = playStoreAuthStore.awaitAuthData()?.locale ?: Locale.getDefault()
+ val date = Date().getFormattedString(DATE_FORMAT, locale)
+ val numberOfUpdatedApps =
+ NumberFormat.getNumberInstance(locale)
+ .format(updatesTracker.successfulUpdatedAppsCount())
+ .toString()
+
+ updatesNotificationSender.showNotification(
+ context.getString(R.string.update),
+ context.getString(
+ R.string.message_last_update_triggered,
+ numberOfUpdatedApps,
+ date
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4fc3687f647bcab5a664209f121e87dba3da52a0
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/core/helper/PreEnqueueChecker.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.data.install.core.helper
+
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationType
+import timber.log.Timber
+import javax.inject.Inject
+
+class PreEnqueueChecker @Inject constructor(
+ private val downloadUrlRefresher: DownloadUrlRefresher,
+ private val appManagerWrapper: AppManagerWrapper,
+ private val ageLimiter: AgeLimiter,
+ private val devicePreconditions: DevicePreconditions,
+) {
+ suspend fun canEnqueue(appInstall: AppInstall, isAnUpdate: Boolean = false): Boolean {
+ val hasUpdatedDownloadUrls = appInstall.type == InstallationType.PWA ||
+ downloadUrlRefresher.updateDownloadUrls(appInstall, isAnUpdate)
+
+ val isDownloadAdded = hasUpdatedDownloadUrls && addDownload(appInstall)
+ val isAgeLimitAllowed = isDownloadAdded && ageLimiter.allow(appInstall)
+
+ return isAgeLimitAllowed && devicePreconditions.canProceed(appInstall)
+ }
+
+ private suspend fun addDownload(appInstall: AppInstall): Boolean {
+ val isDownloadAdded = appManagerWrapper.addDownload(appInstall)
+ if (!isDownloadAdded) {
+ Timber.i("Update adding ABORTED! status")
+ }
+
+ return isDownloadAdded
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt
index 883f37b765eaf18d3429cc80641066c7cba6464c..8a2f6f54d77104ac65adbc44ec1388b2b0a639e3 100644
--- a/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/download/DownloadManagerUtils.kt
@@ -23,12 +23,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
import foundation.e.apps.data.DownloadManager
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
-import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.event.EventBus
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.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.port.InstallationDownloadStatusUpdater
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -48,7 +49,7 @@ class DownloadManagerUtils @Inject constructor(
private val downloadManager: DownloadManager,
private val storageNotificationManager: StorageNotificationManager,
@IoCoroutineScope private val coroutineScope: CoroutineScope
-) {
+) : InstallationDownloadStatusUpdater {
private val mutex = Mutex()
@DelicateCoroutinesApi
@@ -60,7 +61,7 @@ class DownloadManagerUtils @Inject constructor(
}
@DelicateCoroutinesApi
- fun updateDownloadStatus(downloadId: Long) {
+ override fun updateDownloadStatus(downloadId: Long) {
coroutineScope.launch {
mutex.withLock {
// Waiting for DownloadManager to publish the progress of last bytes
@@ -171,7 +172,9 @@ class DownloadManagerUtils @Inject constructor(
}
private suspend fun checkCleanApkSignatureOK(appInstall: AppInstall): Boolean {
- val isNonOpenSource = appInstall.source != Source.PWA && appInstall.source != Source.OPEN_SOURCE
+ val isNonOpenSource =
+ appInstall.source != InstallationSource.PWA &&
+ appInstall.source != InstallationSource.OPEN_SOURCE
val isSigned = appManagerWrapper.isFDroidApplicationSigned(context, appInstall)
if (isNonOpenSource || isSigned) {
Timber.d("Apk signature is OK")
diff --git a/app/src/main/java/foundation/e/apps/data/install/notification/StorageNotificationManager.kt b/app/src/main/java/foundation/e/apps/data/install/notification/StorageNotificationManager.kt
index 77c89ee353faa1e2a97a9a30559e8adb81820cad..9ef06f9053ee850967cd53feac183943a139d514 100644
--- a/app/src/main/java/foundation/e/apps/data/install/notification/StorageNotificationManager.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/notification/StorageNotificationManager.kt
@@ -30,7 +30,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
import foundation.e.apps.data.DownloadManager
import foundation.e.apps.data.di.system.NotificationManagerModule
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.system.StorageComputer
import javax.inject.Inject
diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt
index b60a4ce1511ed97da27c2e79210c751072993e78..cbf25625932e5f00e0be28c718f59d2a76d4c9c1 100644
--- a/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/pkg/AppLoungePackageManager.kt
@@ -32,9 +32,9 @@ import android.os.Build
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.OpenForTesting
-import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.enums.Type
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.DelicateCoroutinesApi
import timber.log.Timber
@@ -121,8 +121,8 @@ class AppLoungePackageManager @Inject constructor(
if (appInstall == null || appInstall.packageName.isBlank()) {
return
}
- if (appInstall.source == Source.PLAY_STORE) {
- if (appInstall.type == Type.NATIVE && isInstalled(FAKE_STORE_PACKAGE_NAME)) {
+ if (appInstall.source == InstallationSource.PLAY_STORE) {
+ if (appInstall.type == InstallationType.NATIVE && isInstalled(FAKE_STORE_PACKAGE_NAME)) {
val targetPackage = appInstall.packageName
try {
packageManager.setInstallerPackageName(targetPackage, FAKE_STORE_PACKAGE_NAME)
diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/PwaManager.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/PwaManager.kt
index 6da37d2d4b785aac700b1763526f7776f46e2d60..4525f34a9c6fc7c93f35bfbbcccdbc411086db0c 100644
--- a/app/src/main/java/foundation/e/apps/data/install/pkg/PwaManager.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/pkg/PwaManager.kt
@@ -14,8 +14,9 @@ import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.OpenForTesting
import foundation.e.apps.data.application.data.Application
-import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.AppInstallIconUrlBuilder
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.delay
import timber.log.Timber
@@ -30,6 +31,7 @@ import javax.inject.Singleton
class PwaManager @Inject constructor(
@ApplicationContext private val context: Context,
private val appInstallRepository: AppInstallRepository,
+ private val appInstallIconUrlBuilder: AppInstallIconUrlBuilder,
) {
companion object {
@@ -147,7 +149,9 @@ class PwaManager @Inject constructor(
appInstallRepository.updateDownload(appInstall)
// Get bitmap and byteArray for icon
- val iconBitmap = getIconImageBitmap(appInstall.getAppIconUrl())
+ val iconBitmap = getIconImageBitmap(
+ appInstallIconUrlBuilder.build(appInstall.source, appInstall.iconImageUrl)
+ )
if (iconBitmap == null) {
appInstall.status = Status.INSTALLATION_ISSUE
diff --git a/app/src/main/java/foundation/e/apps/data/install/receiver/PwaPlayerStatusReceiver.kt b/app/src/main/java/foundation/e/apps/data/install/receiver/PwaPlayerStatusReceiver.kt
index 70a727361ad4a865a355fe66275f67deac67d903..e4ba9b786d3634e28e5ab48aae16fa26711afefd 100644
--- a/app/src/main/java/foundation/e/apps/data/install/receiver/PwaPlayerStatusReceiver.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/receiver/PwaPlayerStatusReceiver.kt
@@ -22,7 +22,7 @@ import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
-import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
diff --git a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt
index c8f7a48a9c416402c7b3f06b15a4205385b0d7dc..ffd2f35a3c479b8b2b877ac8bd7e911eef6b8002 100644
--- a/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/updates/UpdatesWorker.kt
@@ -20,7 +20,7 @@ import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.event.EventBus
import foundation.e.apps.data.gitlab.SystemAppsUpdatesRepository
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
+import foundation.e.apps.data.install.core.AppInstallationFacade
import foundation.e.apps.data.updates.UpdatesManagerRepository
import foundation.e.apps.domain.model.User
import foundation.e.apps.domain.preferences.AppPreferencesRepository
@@ -42,7 +42,7 @@ class UpdatesWorker @AssistedInject constructor(
private val sessionRepository: SessionRepository,
private val appPreferencesRepository: AppPreferencesRepository,
private val playStoreAuthManager: PlayStoreAuthManager,
- private val appInstallProcessor: AppInstallProcessor,
+ private val appInstallationFacade: AppInstallationFacade,
) : CoroutineWorker(context, params) {
companion object {
@@ -220,7 +220,7 @@ class UpdatesWorker @AssistedInject constructor(
response.add(Pair(fusedApp, false))
continue
}
- val status = appInstallProcessor.initAppInstall(fusedApp, true)
+ val status = appInstallationFacade.initAppInstall(fusedApp, true)
response.add(Pair(fusedApp, status))
}
return response
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt
deleted file mode 100644
index e2d33d90c5b46cbd69db31b7c4f630276337c902..0000000000000000000000000000000000000000
--- a/app/src/main/java/foundation/e/apps/data/install/workmanager/AppInstallProcessor.kt
+++ /dev/null
@@ -1,498 +0,0 @@
-/*
- * Copyright MURENA SAS 2023
- * Apps Quickly and easily install Android apps onto your device!
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package foundation.e.apps.data.install.workmanager
-
-import android.content.Context
-import androidx.annotation.VisibleForTesting
-import com.aurora.gplayapi.exceptions.InternalException
-import dagger.hilt.android.qualifiers.ApplicationContext
-import foundation.e.apps.R
-import foundation.e.apps.data.ResultSupreme
-import foundation.e.apps.data.application.ApplicationRepository
-import foundation.e.apps.data.application.UpdatesDao
-import foundation.e.apps.data.application.data.Application
-import foundation.e.apps.data.enums.ResultStatus
-import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.enums.Type
-import foundation.e.apps.data.event.AppEvent
-import foundation.e.apps.data.event.EventBus
-import foundation.e.apps.data.install.AppInstallComponents
-import foundation.e.apps.data.install.AppManager
-import foundation.e.apps.data.install.download.DownloadManagerUtils
-import foundation.e.apps.data.install.models.AppInstall
-import foundation.e.apps.data.install.notification.StorageNotificationManager
-import foundation.e.apps.data.install.updates.UpdatesNotifier
-import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
-import foundation.e.apps.data.preference.PlayStoreAuthStore
-import foundation.e.apps.data.system.ParentalControlAuthenticator
-import foundation.e.apps.data.system.StorageComputer
-import foundation.e.apps.data.system.isNetworkAvailable
-import foundation.e.apps.data.utils.getFormattedString
-import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
-import foundation.e.apps.domain.model.ContentRatingValidity
-import foundation.e.apps.domain.model.User
-import foundation.e.apps.domain.model.install.Status
-import foundation.e.apps.domain.preferences.SessionRepository
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.flow.transformWhile
-import timber.log.Timber
-import java.text.NumberFormat
-import java.util.Date
-import javax.inject.Inject
-
-@Suppress("LongParameterList")
-class AppInstallProcessor @Inject constructor(
- @ApplicationContext private val context: Context,
- private val appInstallComponents: AppInstallComponents,
- private val applicationRepository: ApplicationRepository,
- private val validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase,
- private val sessionRepository: SessionRepository,
- private val playStoreAuthStore: PlayStoreAuthStore,
- private val storageNotificationManager: StorageNotificationManager,
-) {
- @Inject
- lateinit var downloadManager: DownloadManagerUtils
-
- @Inject
- lateinit var appManager: AppManager
-
- private var isItUpdateWork = false
-
- companion object {
- private const val TAG = "AppInstallProcessor"
- private const val DATE_FORMAT = "dd/MM/yyyy-HH:mm"
- }
-
- /**
- * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process.
- * @param application represents the app info which will be installed
- * @param isAnUpdate indicates the app is requested for update or not
- *
- */
- suspend fun initAppInstall(
- application: Application,
- isAnUpdate: Boolean = false
- ): Boolean {
- val appInstall = AppInstall(
- application._id,
- application.source,
- application.status,
- application.name,
- application.package_name,
- mutableListOf(),
- mutableMapOf(),
- application.status,
- application.type,
- application.icon_image_path,
- application.latest_version_code,
- application.offer_type,
- application.isFree,
- application.originalSize
- ).also {
- it.contentRating = application.contentRating
- }
-
- if (appInstall.type == Type.PWA || application.source == Source.SYSTEM_APP) {
- appInstall.downloadURLList = mutableListOf(application.url)
- }
-
- val isUpdate = isAnUpdate ||
- application.status == Status.UPDATABLE ||
- appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(appInstall)
-
- return enqueueFusedDownload(appInstall, isUpdate, application.isSystemApp)
- }
-
- /**
- * Enqueues [AppInstall] into WorkManager to run app install process. Before enqueuing,
- * It validates some corner cases
- * @param appInstall represents the app downloading and installing related info, example- Installing Status,
- * Url of the APK,OBB files are needed to be downloaded and installed etc.
- * @param isAnUpdate indicates the app is requested for update or not
- */
- suspend fun enqueueFusedDownload(
- appInstall: AppInstall,
- isAnUpdate: Boolean = false,
- isSystemApp: Boolean = false
- ): Boolean {
- val uniqueWorkName = InstallWorkManager.getUniqueWorkName(appInstall.packageName)
-
- return try {
- val user = sessionRepository.awaitUser()
- if (!isSystemApp && (user == User.GOOGLE || user == User.ANONYMOUS)) {
- val authData = playStoreAuthStore.awaitAuthData()
- if (!appInstall.isFree && authData?.isAnonymous == true) {
- EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.paid_app_anonymous_message))
- }
- }
-
- if (!canEnqueue(appInstall)) return false
-
- appInstallComponents.appManagerWrapper.updateAwaiting(appInstall)
-
- // Use only for update work for now. For installation work, see InstallOrchestrator#observeDownloads()
- if (isAnUpdate) {
- InstallWorkManager.enqueueWork(context, appInstall, true)
- Timber.d("UPDATE: Successfully enqueued unique work: $uniqueWorkName")
- }
- true
- } catch (e: Exception) {
- Timber.e(e, "UPDATE: Failed to enqueue unique work for ${appInstall.packageName}")
- appInstallComponents.appManagerWrapper.installationIssue(appInstall)
- false
- }
- }
-
- @VisibleForTesting
- suspend fun canEnqueue(appInstall: AppInstall): Boolean {
- if (appInstall.type != Type.PWA && !updateDownloadUrls(appInstall)) {
- return false
- }
-
- if (!appInstallComponents.appManagerWrapper.addDownload(appInstall)) {
- Timber.i("Update adding ABORTED! status")
- return false
- }
-
- if (!validateAgeLimit(appInstall)) {
- return false
- }
-
- if (!context.isNetworkAvailable()) {
- appInstallComponents.appManagerWrapper.installationIssue(appInstall)
- EventBus.invokeEvent(AppEvent.NoInternetEvent(false))
- return false
- }
-
- if (StorageComputer.spaceMissing(appInstall) > 0) {
- Timber.d("Storage is not available for: ${appInstall.name} size: ${appInstall.appSize}")
- storageNotificationManager.showNotEnoughSpaceNotification(appInstall)
- appInstallComponents.appManagerWrapper.installationIssue(appInstall)
- EventBus.invokeEvent(AppEvent.ErrorMessageEvent(R.string.not_enough_storage))
- return false
- }
-
- return true
- }
-
- private suspend fun validateAgeLimit(appInstall: AppInstall): Boolean {
- val ageLimitValidationResult = validateAppAgeLimitUseCase(appInstall)
- if (ageLimitValidationResult.data?.isValid == true) {
- return true
- }
- if (ageLimitValidationResult.isSuccess()) {
- awaitInvokeAgeLimitEvent(appInstall.name)
- if (ageLimitValidationResult.data?.requestPin == true) {
- val isAuthenticated = ParentalControlAuthenticator.awaitAuthentication()
- if (isAuthenticated) {
- ageLimitValidationResult.setData(ContentRatingValidity(true))
- }
- }
- } else {
- EventBus.invokeEvent(AppEvent.ErrorMessageDialogEvent(R.string.data_load_error_desc))
- }
-
- var ageIsValid = true
- if (ageLimitValidationResult.data?.isValid != true) {
- appInstallComponents.appManagerWrapper.cancelDownload(appInstall)
- ageIsValid = false
- }
- return ageIsValid
- }
-
- suspend fun awaitInvokeAgeLimitEvent(type: String) {
- val deferred = CompletableDeferred()
- EventBus.invokeEvent(AppEvent.AgeLimitRestrictionEvent(type, deferred))
- deferred.await() // await closing dialog box
- }
-
- // returns TRUE if updating urls is successful, otherwise false.
- private suspend fun updateDownloadUrls(appInstall: AppInstall): Boolean {
- try {
- updateFusedDownloadWithAppDownloadLink(appInstall)
- } catch (e: InternalException.AppNotPurchased) {
- if (appInstall.isFree) {
- handleAppRestricted(appInstall)
- return false
- }
- appInstallComponents.appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall)
- EventBus.invokeEvent(AppEvent.AppPurchaseEvent(appInstall))
- return false
- } catch (e: GplayHttpRequestException) {
- handleUpdateDownloadError(
- appInstall,
- "${appInstall.packageName} code: ${e.status} exception: ${e.localizedMessage}",
- e
- )
- return false
- } catch (e: IllegalStateException) {
- Timber.e(e)
- } catch (e: Exception) {
- handleUpdateDownloadError(
- appInstall,
- "${appInstall.packageName} exception: ${e.localizedMessage}",
- e
- )
- return false
- }
- return true
- }
-
- private suspend fun handleAppRestricted(appInstall: AppInstall) {
- EventBus.invokeEvent(AppEvent.AppRestrictedOrUnavailable(appInstall))
- appManager.addDownload(appInstall)
- appManager.updateUnavailable(appInstall)
- }
-
- private suspend fun handleUpdateDownloadError(
- appInstall: AppInstall,
- message: String,
- e: Exception
- ) {
- Timber.e(e, "Updating download Urls failed for $message")
- EventBus.invokeEvent(
- AppEvent.UpdateEvent(
- ResultSupreme.WorkError(
- ResultStatus.UNKNOWN,
- appInstall
- )
- )
- )
- }
-
- private suspend fun updateFusedDownloadWithAppDownloadLink(
- appInstall: AppInstall
- ) {
- applicationRepository.updateFusedDownloadWithDownloadingInfo(
- appInstall.source,
- appInstall
- )
- }
-
- @OptIn(DelicateCoroutinesApi::class)
- suspend fun processInstall(
- fusedDownloadId: String,
- isItUpdateWork: Boolean,
- runInForeground: (suspend (String) -> Unit)
- ): Result {
- var appInstall: AppInstall? = null
- try {
- Timber.d("Fused download name $fusedDownloadId")
-
- appInstall = appInstallComponents.appInstallRepository.getDownloadById(fusedDownloadId)
- Timber.i(">>> dowork started for Fused download name " + appInstall?.name + " " + fusedDownloadId)
-
- appInstall?.let {
- checkDownloadingState(appInstall)
-
- this.isItUpdateWork =
- isItUpdateWork && appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(
- appInstall
- )
-
- if (!appInstall.isAppInstalling()) {
- Timber.d("!!! returned")
- return@let
- }
-
- if (!appInstallComponents.appManagerWrapper.validateFusedDownload(appInstall)) {
- appInstallComponents.appManagerWrapper.installationIssue(it)
- Timber.d("!!! installationIssue")
- return@let
- }
-
- if (areFilesDownloadedButNotInstalled(appInstall)) {
- Timber.i("===> Downloaded But not installed ${appInstall.name}")
- appInstallComponents.appManagerWrapper.updateDownloadStatus(
- appInstall,
- Status.INSTALLING
- )
- }
-
- runInForeground.invoke(it.name)
-
- startAppInstallationProcess(it)
- }
- } catch (e: Exception) {
- Timber.e(
- e,
- "Install worker is failed for ${appInstall?.packageName} exception: ${e.localizedMessage}"
- )
- appInstall?.let {
- appInstallComponents.appManagerWrapper.cancelDownload(appInstall)
- }
- }
-
- Timber.i("doWork: RESULT SUCCESS: ${appInstall?.name}")
- return Result.success(ResultStatus.OK)
- }
-
- @OptIn(DelicateCoroutinesApi::class)
- private fun checkDownloadingState(appInstall: AppInstall) {
- if (appInstall.status == Status.DOWNLOADING) {
- appInstall.downloadIdMap.keys.forEach { downloadId ->
- downloadManager.updateDownloadStatus(downloadId)
- }
- }
- }
-
- private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall) =
- appInstall.areFilesDownloaded() && (!appInstallComponents.appManagerWrapper.isFusedDownloadInstalled(
- appInstall
- ) || appInstall.status == Status.INSTALLING)
-
- private suspend fun checkUpdateWork(
- appInstall: AppInstall?
- ) {
- if (isItUpdateWork) {
- appInstall?.let {
- val packageStatus =
- appInstallComponents.appManagerWrapper.getFusedDownloadPackageStatus(appInstall)
-
- if (packageStatus == Status.INSTALLED) {
- UpdatesDao.addSuccessfullyUpdatedApp(it)
- }
-
- if (isUpdateCompleted()) { // show notification for ended update
- showNotificationOnUpdateEnded()
- UpdatesDao.clearSuccessfullyUpdatedApps()
- }
- }
- }
- }
-
- private suspend fun isUpdateCompleted(): Boolean {
- val downloadListWithoutAnyIssue =
- appInstallComponents.appInstallRepository.getDownloadList().filter {
- !listOf(
- Status.INSTALLATION_ISSUE,
- Status.PURCHASE_NEEDED
- ).contains(it.status)
- }
-
- return UpdatesDao.successfulUpdatedApps.isNotEmpty() && downloadListWithoutAnyIssue.isEmpty()
- }
-
- private suspend fun showNotificationOnUpdateEnded() {
- val locale = playStoreAuthStore.awaitAuthData()?.locale ?: java.util.Locale.getDefault()
- val date = Date().getFormattedString(DATE_FORMAT, locale)
- val numberOfUpdatedApps =
- NumberFormat.getNumberInstance(locale).format(UpdatesDao.successfulUpdatedApps.size)
- .toString()
-
- UpdatesNotifier.showNotification(
- context,
- context.getString(R.string.update),
- context.getString(
- R.string.message_last_update_triggered,
- numberOfUpdatedApps,
- date
- )
- )
- }
-
- private suspend fun startAppInstallationProcess(appInstall: AppInstall) {
- if (appInstall.isAwaiting()) {
- appInstallComponents.appManagerWrapper.downloadApp(appInstall, isItUpdateWork)
- Timber.i("===> doWork: Download started ${appInstall.name} ${appInstall.status}")
- }
-
- appInstallComponents.appInstallRepository.getDownloadFlowById(appInstall.id)
- .transformWhile {
- emit(it)
- isInstallRunning(it)
- }
- .collect { latestFusedDownload ->
- handleFusedDownload(latestFusedDownload, appInstall)
- }
- }
-
- /**
- * Takes actions depending on the status of [AppInstall]
- *
- * @param latestAppInstall comes from Room database when [Status] is updated
- * @param appInstall is the original object when install process isn't started. It's used when [latestAppInstall]
- * becomes null, After installation is completed.
- */
- private suspend fun handleFusedDownload(
- latestAppInstall: AppInstall?,
- appInstall: AppInstall
- ) {
- if (latestAppInstall == null) {
- Timber.d("===> download null: finish installation")
- finishInstallation(appInstall)
- return
- }
-
- handleFusedDownloadStatusCheckingException(latestAppInstall)
- }
-
- private fun isInstallRunning(it: AppInstall?) =
- it != null && it.status != Status.INSTALLATION_ISSUE
-
- private suspend fun handleFusedDownloadStatusCheckingException(
- download: AppInstall
- ) {
- try {
- handleFusedDownloadStatus(download)
- } catch (e: Exception) {
- val message =
- "Handling install status is failed for ${download.packageName} exception: ${e.localizedMessage}"
- Timber.e(e, message)
- appInstallComponents.appManagerWrapper.installationIssue(download)
- finishInstallation(download)
- }
- }
-
- private suspend fun handleFusedDownloadStatus(appInstall: AppInstall) {
- when (appInstall.status) {
- Status.AWAITING, Status.DOWNLOADING -> {
- }
-
- Status.DOWNLOADED -> {
- appInstallComponents.appManagerWrapper.updateDownloadStatus(
- appInstall,
- Status.INSTALLING
- )
- }
-
- Status.INSTALLING -> {
- Timber.i("===> doWork: Installing ${appInstall.name} ${appInstall.status}")
- }
-
- Status.INSTALLED, Status.INSTALLATION_ISSUE -> {
- Timber.i("===> doWork: Installed/Failed: ${appInstall.name} ${appInstall.status}")
- finishInstallation(appInstall)
- }
-
- else -> {
- Timber.wtf(
- TAG,
- "===> ${appInstall.name} is in wrong state ${appInstall.status}"
- )
- finishInstallation(appInstall)
- }
- }
- }
-
- private suspend fun finishInstallation(appInstall: AppInstall) {
- checkUpdateWork(appInstall)
- }
-}
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt
index c4af7cfbb4a7bd717c6f2351a9923ecff40d439a..b095fb55f5d8740fe3f28a7623ff5500d565a2bc 100644
--- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallAppWorker.kt
@@ -32,13 +32,14 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import foundation.e.apps.R
+import foundation.e.apps.data.install.core.AppInstallationFacade
import java.util.concurrent.atomic.AtomicInteger
@HiltWorker
class InstallAppWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
- private val appInstallProcessor: AppInstallProcessor
+ private val appInstallationFacade: AppInstallationFacade
) : CoroutineWorker(context, params) {
companion object {
@@ -59,15 +60,20 @@ class InstallAppWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
val fusedDownloadId = params.inputData.getString(INPUT_DATA_FUSED_DOWNLOAD) ?: ""
+ if (fusedDownloadId.isEmpty()) {
+ return Result.failure()
+ }
+
val isPackageUpdate = params.inputData.getBoolean(IS_UPDATE_WORK, false)
- val response = appInstallProcessor.processInstall(fusedDownloadId, isPackageUpdate) { title ->
- setForeground(
- createForegroundInfo(
- "${context.getString(R.string.installing)} $title"
- )
- )
+ val response = appInstallationFacade.processInstall(fusedDownloadId, isPackageUpdate) { title ->
+ setForeground(createForegroundInfo("${context.getString(R.string.installing)} $title"))
+ }
+
+ return if (response.isSuccess) {
+ Result.success()
+ } else {
+ Result.failure()
}
- return if (response.isSuccess) Result.success() else Result.failure()
}
private fun createForegroundInfo(progress: String): ForegroundInfo {
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt
index a5f6718a0dec88c567c338b23b252d4a15b00f67..ab8117693072e01bee0b7a6201835d62d0ae0815 100644
--- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallOrchestrator.kt
@@ -24,9 +24,9 @@ import androidx.work.WorkManager
import androidx.work.await
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.data.di.qualifiers.IoCoroutineScope
-import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -36,12 +36,11 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
-
class InstallOrchestrator @Inject constructor(
@param:ApplicationContext val context: Context,
@param:IoCoroutineScope private val scope: CoroutineScope,
private val appManagerWrapper: AppManagerWrapper,
- private val installDao: AppInstallDAO
+ private val appInstallRepository: AppInstallRepository
) {
fun init() {
@@ -56,7 +55,7 @@ class InstallOrchestrator @Inject constructor(
}
private fun observeDownloads() {
- installDao.getDownloads().onEach { list ->
+ appInstallRepository.getDownloads().onEach { list ->
runCatching {
if (list.none { it.status == Status.DOWNLOADING || it.status == Status.INSTALLING }) {
list.find { it.status == Status.AWAITING }
@@ -94,7 +93,7 @@ class InstallOrchestrator @Inject constructor(
private suspend fun cancelFailedDownloads() {
val workManager = WorkManager.getInstance(context)
- val apps = installDao.getDownloads().firstOrNull().orEmpty()
+ val apps = appInstallRepository.getDownloads().firstOrNull().orEmpty()
val activeWorkStates = setOf(
WorkInfo.State.ENQUEUED,
diff --git a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt
index 90c5e1771064ce4fcfe06949448147011caff705..27bccf90b35c64240a581a4416fa689efb7c3fd9 100644
--- a/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt
+++ b/app/src/main/java/foundation/e/apps/data/install/workmanager/InstallWorkManager.kt
@@ -7,7 +7,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import timber.log.Timber
object InstallWorkManager {
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/DeviceNetworkStatusChecker.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/DeviceNetworkStatusChecker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f278602d2ec9d6b919242cc623af54c7910bfcef
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/DeviceNetworkStatusChecker.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 android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.data.installation.port.NetworkStatusChecker
+import foundation.e.apps.data.system.isNetworkAvailable
+import javax.inject.Inject
+
+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/ParentalControlAuthGatewayImpl.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGatewayImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..864d9c13b0f2902777d18813059fbe56dde67b13
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/ParentalControlAuthGatewayImpl.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.installation.port.ParentalControlAuthGateway
+import foundation.e.apps.data.system.ParentalControlAuthenticator
+import javax.inject.Inject
+
+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/StorageSpaceCheckerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceCheckerImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c3cda13e5f8116e4c4529605a39367d1b85ba514
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/StorageSpaceCheckerImpl.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.StorageSpaceChecker
+import foundation.e.apps.data.system.StorageComputer
+import javax.inject.Inject
+
+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/UpdatesNotificationSenderImpl.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSenderImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..213a8aea9c391830da19453c069f5e69917b952c
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesNotificationSenderImpl.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 android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import foundation.e.apps.data.install.updates.UpdatesNotifier
+import foundation.e.apps.data.installation.port.UpdatesNotificationSender
+import javax.inject.Inject
+
+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/UpdatesTrackerImpl.kt b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTrackerImpl.kt
new file mode 100644
index 0000000000000000000000000000000000000000..963897ac2d8320f3aef09d53f08757c8f1d8d1fd
--- /dev/null
+++ b/app/src/main/java/foundation/e/apps/data/install/wrapper/UpdatesTrackerImpl.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.installation.model.AppInstall
+import foundation.e.apps.data.installation.port.UpdatesTracker
+import javax.inject.Inject
+
+class UpdatesTrackerImpl @Inject constructor() : UpdatesTracker {
+ override fun addSuccessfullyUpdatedApp(appInstall: AppInstall) {
+ UpdatesDao.addSuccessfullyUpdatedApp(appInstall)
+ }
+
+ override fun clearSuccessfullyUpdatedApps() {
+ UpdatesDao.clearSuccessfullyUpdatedApps()
+ }
+
+ override fun hasSuccessfulUpdatedApps(): Boolean {
+ return UpdatesDao.successfulUpdatedApps.isNotEmpty()
+ }
+
+ override fun successfulUpdatedAppsCount(): Int {
+ return UpdatesDao.successfulUpdatedApps.size
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/provider/AgeRatingProvider.kt b/app/src/main/java/foundation/e/apps/data/provider/AgeRatingProvider.kt
index 2c62ef42b1686d456bff55c60c2822feb01d967b..d41184975500920b80b6c96b0a9aa47ecc410370 100644
--- a/app/src/main/java/foundation/e/apps/data/provider/AgeRatingProvider.kt
+++ b/app/src/main/java/foundation/e/apps/data/provider/AgeRatingProvider.kt
@@ -45,8 +45,9 @@ import foundation.e.apps.contract.ParentalControlContract.TOTAL_PACKAGE_NUMBER
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.blockedApps.BlockedAppRepository
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
+import foundation.e.apps.data.install.toInstallationSource
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.login.exceptions.GPlayLoginException
import foundation.e.apps.data.parentalcontrol.ContentRatingDao
import foundation.e.apps.data.parentalcontrol.ContentRatingEntity
@@ -64,7 +65,6 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import timber.log.Timber
-
class AgeRatingProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -224,7 +224,7 @@ class AgeRatingProvider : ContentProvider() {
private suspend fun isAppValidRegardingAge(packageName: String): Boolean? {
val fakeAppInstall = AppInstall(
packageName = packageName,
- source = Source.PLAY_STORE
+ source = Source.PLAY_STORE.toInstallationSource()
)
val validateResult = validateAppAgeLimitUseCase(fakeAppInstall)
saveContentRatingIfInvalid(validateResult, packageName)
@@ -250,7 +250,7 @@ class AgeRatingProvider : ContentProvider() {
private suspend fun isAppValidRegardingNSFW(packageName: String): Boolean {
val fakeAppInstall = AppInstall(
packageName = packageName,
- source = Source.OPEN_SOURCE,
+ source = Source.OPEN_SOURCE.toInstallationSource(),
)
val validateResult = validateAppAgeLimitUseCase(fakeAppInstall)
return validateResult.data?.isValid ?: false
diff --git a/app/src/main/java/foundation/e/apps/data/receivers/DumpAppInstallStatusReceiver.kt b/app/src/main/java/foundation/e/apps/data/receivers/DumpAppInstallStatusReceiver.kt
index 9cdf33596f52d703d4691bd6d3400961785a8437..274951b0b153666d43aa4e0a00fdd30c5bc15247 100644
--- a/app/src/main/java/foundation/e/apps/data/receivers/DumpAppInstallStatusReceiver.kt
+++ b/app/src/main/java/foundation/e/apps/data/receivers/DumpAppInstallStatusReceiver.kt
@@ -28,8 +28,8 @@ import com.google.gson.Gson
import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.data.Constants
import foundation.e.apps.data.DownloadManager
-import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.data.system.NetworkStatusManager
import foundation.e.apps.data.system.StorageComputer
import foundation.e.apps.domain.model.install.Status
diff --git a/app/src/main/java/foundation/e/apps/data/system/StorageComputer.kt b/app/src/main/java/foundation/e/apps/data/system/StorageComputer.kt
index a1f1a50efd36bd4347821b8b2a7ad8476a831f09..06cd89dd645765fc9b5fa0c727f3b4df0abda66b 100644
--- a/app/src/main/java/foundation/e/apps/data/system/StorageComputer.kt
+++ b/app/src/main/java/foundation/e/apps/data/system/StorageComputer.kt
@@ -19,7 +19,7 @@ package foundation.e.apps.data.system
import android.os.Environment
import android.os.StatFs
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import java.text.CharacterIterator
import java.text.StringCharacterIterator
import java.util.Locale
diff --git a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt
index 691401e21c72759b26f88b67efa565075428929a..2b450ab804fc8ebc3bf9d976db2b817c14a990a9 100644
--- a/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt
+++ b/app/src/main/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCase.kt
@@ -24,9 +24,9 @@ import foundation.e.apps.contract.ParentalControlContract.Age
import foundation.e.apps.data.ResultSupreme
import foundation.e.apps.data.application.apps.AppsApi
import foundation.e.apps.data.blockedApps.BlockedAppRepository
-import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.enums.Type
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
import foundation.e.apps.data.parentalcontrol.ContentRatingDao
import foundation.e.apps.data.parentalcontrol.ParentalControlRepository
import foundation.e.apps.data.parentalcontrol.ParentalControlRepository.Companion.KEY_PARENTAL_GUIDANCE
@@ -91,17 +91,17 @@ class ValidateAppAgeLimitUseCase @Inject constructor(
}
private fun isGitlabApp(app: AppInstall): Boolean {
- return app.source == Source.SYSTEM_APP
+ return app.source == InstallationSource.SYSTEM_APP
}
private fun isCleanApkApp(app: AppInstall): Boolean {
return app.id.isNotBlank()
- && (app.source == Source.PWA || app.source == Source.OPEN_SOURCE)
- && app.type == Type.NATIVE
+ && (app.source == InstallationSource.PWA || app.source == InstallationSource.OPEN_SOURCE)
+ && app.type == InstallationType.NATIVE
}
private fun isWhiteListedCleanApkApp(app: AppInstall): Boolean {
- return app.source == Source.OPEN_SOURCE || app.source == Source.PWA
+ return app.source == InstallationSource.OPEN_SOURCE || app.source == InstallationSource.PWA
}
private suspend fun isNsfwAppByCleanApkApi(app: AppInstall): Boolean {
@@ -140,7 +140,7 @@ class ValidateAppAgeLimitUseCase @Inject constructor(
}
private suspend fun hasNoContentRatingOnGPlay(app: AppInstall): Boolean {
- return app.source == Source.PLAY_STORE && !verifyContentRatingExists(app)
+ return app.source == InstallationSource.PLAY_STORE && !verifyContentRatingExists(app)
}
private fun isValidAppAgeRating(
diff --git a/app/src/main/java/foundation/e/apps/domain/install/InstallAppByIdUseCase.kt b/app/src/main/java/foundation/e/apps/domain/install/InstallAppByIdUseCase.kt
index f4dd5983edc7406ae602c7763329a70e172e2862..155e7a3fb85cc774d38b6f90cac61b96a5089ac9 100644
--- a/app/src/main/java/foundation/e/apps/domain/install/InstallAppByIdUseCase.kt
+++ b/app/src/main/java/foundation/e/apps/domain/install/InstallAppByIdUseCase.kt
@@ -20,10 +20,10 @@ package foundation.e.apps.domain.install
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.install.AppInstallRepository
+import foundation.e.apps.data.install.core.AppInstallationFacade
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +33,7 @@ import javax.inject.Inject
class InstallAppByIdUseCase @Inject constructor(
private val getAppDetailsUseCase: GetAppDetailsUseCase,
private val appLoungePackageManager: AppLoungePackageManager,
- private val appInstallProcessor: AppInstallProcessor,
+ private val appInstallationFacade: AppInstallationFacade,
private val appInstallRepository: AppInstallRepository,
private val pwaManager: PwaManager
) {
@@ -49,7 +49,7 @@ class InstallAppByIdUseCase @Inject constructor(
return try {
val app: Application = getAppDetailsUseCase(packageName)
- appInstallProcessor.initAppInstall(app, isAnUpdate = false)
+ appInstallationFacade.initAppInstall(app, isAnUpdate = false)
appInstallRepository.getDownloadFlowById(app._id).takeWhile {
_status.value = it?.status
diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt
index e972acd6d60a374a102360d914d562f90749deb2..205b84c659b9a6556532c7f3dc1ad60358ab17bf 100644
--- a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt
+++ b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt
@@ -50,8 +50,8 @@ import foundation.e.apps.data.Constants
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.event.EventBus
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.updates.UpdatesNotifier
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.login.core.StoreType
import foundation.e.apps.data.system.ParentalControlAuthenticator
import foundation.e.apps.databinding.ActivityMainBinding
@@ -72,7 +72,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.resume
-
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var signInViewModel: SignInViewModel
diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt
index b7e2cd50deb4a2c1dde35580e897d62a5ec2cc29..512bebe82e05795d90938dda53575c522c5f514d 100644
--- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt
+++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt
@@ -33,10 +33,11 @@ import foundation.e.apps.data.application.mapper.toApplication
import foundation.e.apps.data.enums.isInitialized
import foundation.e.apps.data.enums.isUnFiltered
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.core.AppInstallationFacade
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
+import foundation.e.apps.data.install.toInstallationSource
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.login.core.AuthObject
import foundation.e.apps.data.system.NetworkStatusManager
import foundation.e.apps.domain.application.ApplicationDomain
@@ -49,14 +50,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
-
@HiltViewModel
class MainActivityViewModel @Inject constructor(
private val applicationRepository: ApplicationRepository,
private val appManagerWrapper: AppManagerWrapper,
private val appLoungePackageManager: AppLoungePackageManager,
private val pwaManager: PwaManager,
- private val appInstallProcessor: AppInstallProcessor,
+ private val appInstallationFacade: AppInstallationFacade,
private val sessionManager: MainActivitySessionManager,
private val startupCoordinator: MainActivityStartupCoordinator,
) : ViewModel() {
@@ -295,7 +295,7 @@ class MainActivityViewModel @Inject constructor(
fun getApplication(app: Application) {
viewModelScope.launch(Dispatchers.IO) {
- appInstallProcessor.initAppInstall(app)
+ appInstallationFacade.initAppInstall(app)
}
}
@@ -307,7 +307,7 @@ class MainActivityViewModel @Inject constructor(
val fusedDownload = appManagerWrapper.getFusedDownload(packageName = packageName)
val authData = sessionManager.awaitAuthData()
if (authData?.isAnonymous != true) {
- appInstallProcessor.enqueueFusedDownload(fusedDownload)
+ appInstallationFacade.enqueueFusedDownload(fusedDownload)
return fusedDownload
}
@@ -341,7 +341,7 @@ class MainActivityViewModel @Inject constructor(
) {
applicationList.forEach {
val downloadingItem = appInstallList.find { fusedDownload ->
- fusedDownload.source == it.source &&
+ fusedDownload.source == it.source.toInstallationSource() &&
(fusedDownload.packageName == it.package_name || fusedDownload.id == it._id)
}
it.status =
@@ -355,7 +355,7 @@ class MainActivityViewModel @Inject constructor(
): List {
return homeApps.map { homeApp ->
val downloadingItem = appInstallList.find { fusedDownload ->
- fusedDownload.source == homeApp.source &&
+ fusedDownload.source == homeApp.source.toInstallationSource() &&
(fusedDownload.packageName == homeApp.packageName || fusedDownload.id == homeApp.id)
}
val status = downloadingItem?.status
diff --git a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt
index de36a579a46b40e7635b45feb4eae949e3a790ab..186eaa407524f2f8f420a47b90d0f117ddc3e2ca 100644
--- a/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt
+++ b/app/src/main/java/foundation/e/apps/ui/application/ApplicationViewModel.kt
@@ -35,7 +35,7 @@ import foundation.e.apps.data.event.EventBus
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.download.data.DownloadProgress
import foundation.e.apps.data.install.download.data.DownloadProgressLD
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.login.core.AuthObject
import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository
import foundation.e.apps.data.playstore.PlayStoreRepository
diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt
index 4546e9023885a61413b21a292a2aef58d4c80453..472b1d70e0b35e65a6cc5293455968f6ed85e08f 100644
--- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt
+++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt
@@ -22,7 +22,7 @@ import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.download.data.DownloadProgress
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import javax.inject.Inject
import javax.inject.Singleton
diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt
index 38bdf582a39015167c97af5ce6efdfaafcd99c50..b15c5e2ffc526abb33aef9631dfb428138c8139e 100644
--- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt
+++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusStream.kt
@@ -31,9 +31,9 @@ package foundation.e.apps.ui.compose.state
import androidx.lifecycle.asFlow
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
+import foundation.e.apps.data.installation.model.AppInstall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -46,7 +46,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
-
/*
* Snapshot of device install state needed for UI reconciliation.
*
diff --git a/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt b/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt
index 874e6b0c7533082bdd1994a54d4aea4a3ccdc665..a52243d12d144ab67e51e99ec1c15522d5de588d 100644
--- a/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt
+++ b/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt
@@ -44,8 +44,8 @@ import foundation.e.apps.R
import foundation.e.apps.data.application.ApplicationInstaller
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.install.download.data.DownloadProgress
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.pkg.PwaManager
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.system.isNetworkAvailable
import foundation.e.apps.databinding.FragmentSearchBinding
import foundation.e.apps.domain.model.install.Status
@@ -59,7 +59,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
-
@AndroidEntryPoint
class SearchFragment : Fragment(R.layout.fragment_search), ApplicationInstaller,
SearchViewHandler.SearchViewListener {
diff --git a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt
index 5637aa273c80e581492cddf6d6c027a2d7f9822a..1d9f7cd90e8ca22486ba47b7784f03b4c80197ff 100644
--- a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt
+++ b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesFragment.kt
@@ -43,10 +43,10 @@ import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.event.AppEvent
import foundation.e.apps.data.event.EventBus
import foundation.e.apps.data.install.download.data.DownloadProgress
-import foundation.e.apps.data.install.models.AppInstall
import foundation.e.apps.data.install.pkg.PwaManager
import foundation.e.apps.data.install.updates.UpdatesWorkManager
import foundation.e.apps.data.install.workmanager.InstallWorkManager
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.login.core.AuthObject
import foundation.e.apps.data.login.exceptions.GPlayException
import foundation.e.apps.data.login.exceptions.GPlayLoginException
@@ -67,7 +67,6 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
-
@AndroidEntryPoint
class UpdatesFragment : TimeoutFragment(R.layout.fragment_updates), ApplicationInstaller {
diff --git a/app/src/test/java/foundation/e/apps/data/application/ApplicationRepositoryHomeTest.kt b/app/src/test/java/foundation/e/apps/data/application/ApplicationRepositoryHomeTest.kt
index 20921e5713f83c13f7092d521155598965ecbd78..02db7e9324499bd8508f7cbf403599e3a912f058 100644
--- a/app/src/test/java/foundation/e/apps/data/application/ApplicationRepositoryHomeTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/application/ApplicationRepositoryHomeTest.kt
@@ -38,7 +38,7 @@ import foundation.e.apps.data.enums.FilterLevel
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
import foundation.e.apps.domain.model.install.Status
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.util.MainCoroutineRule
import io.mockk.coEvery
import io.mockk.coVerify
diff --git a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt
index acf4acaf0b11c22d739c568e5eb4aabe1789d075..5d2f946f93c0bf2e4760d4c0ed29a3105d135a0d 100644
--- a/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/database/install/AppInstallConverterTest.kt
@@ -3,7 +3,7 @@ package foundation.e.apps.data.database.install
import com.aurora.gplayapi.data.models.ContentRating
import com.aurora.gplayapi.data.models.PlayFile
import com.google.common.truth.Truth.assertThat
-import foundation.e.apps.data.database.install.AppInstallConverter
+import foundation.e.apps.data.installation.local.AppInstallConverter
import org.junit.Test
class AppInstallConverterTest {
diff --git a/app/src/test/java/foundation/e/apps/data/event/AppEventTest.kt b/app/src/test/java/foundation/e/apps/data/event/AppEventTest.kt
index c3dd00dfb82e678820342c7e7de4b2059a843fad..985f9c63e6e51bf7ea72a3811b6c96b6406de447 100644
--- a/app/src/test/java/foundation/e/apps/data/event/AppEventTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/event/AppEventTest.kt
@@ -3,7 +3,7 @@ package foundation.e.apps.data.event
import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.domain.model.User
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.ResultSupreme
import kotlinx.coroutines.CompletableDeferred
import org.junit.Test
diff --git a/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt b/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt
index 5d80897dd9d4755b9a927e1e2cc3370f114b3b17..b90d7cb07c63c82a0a023d7386ed19206bca1bc8 100644
--- a/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/install/AppManagerWrapperProgressTest.kt
@@ -20,7 +20,7 @@ package foundation.e.apps.data.install
import android.content.Context
import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.fdroid.FDroidRepository
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.install.download.data.DownloadProgress
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
diff --git a/app/src/test/java/foundation/e/apps/data/install/models/AppInstallTest.kt b/app/src/test/java/foundation/e/apps/data/install/models/AppInstallTest.kt
index a33c9a8a34d1e0e3d5d2d300010690e37ac0a4f9..ff4b00b1423c702dc70349c3785118bf4387a46a 100644
--- a/app/src/test/java/foundation/e/apps/data/install/models/AppInstallTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/install/models/AppInstallTest.kt
@@ -2,11 +2,14 @@ package foundation.e.apps.data.install.models
import com.google.common.truth.Truth.assertThat
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
-import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.install.AppInstallIconUrlBuilder
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationSource
import foundation.e.apps.domain.model.install.Status
import org.junit.Test
class AppInstallTest {
+ private val iconUrlBuilder = AppInstallIconUrlBuilder()
@Test
fun isAppInstalling_matchesDownloadingStates() {
@@ -38,13 +41,13 @@ class AppInstallTest {
}
@Test
- fun getAppIconUrl_usesAssetUrlForPlayStoreAndPwa() {
- val playStoreApp = AppInstall(source = Source.PLAY_STORE, iconImageUrl = "icon.png")
- val pwaApp = AppInstall(source = Source.PWA, iconImageUrl = "icon2.png")
- val fossApp = AppInstall(source = Source.OPEN_SOURCE, iconImageUrl = "https://example/icon")
-
- assertThat(playStoreApp.getAppIconUrl()).isEqualTo("${CleanApkRetrofit.ASSET_URL}icon.png")
- assertThat(pwaApp.getAppIconUrl()).isEqualTo("${CleanApkRetrofit.ASSET_URL}icon2.png")
- assertThat(fossApp.getAppIconUrl()).isEqualTo("https://example/icon")
+ fun build_usesAssetUrlForPlayStoreAndPwa() {
+ val playStoreApp = AppInstall(source = InstallationSource.PLAY_STORE, iconImageUrl = "icon.png")
+ val pwaApp = AppInstall(source = InstallationSource.PWA, iconImageUrl = "icon2.png")
+ val fossApp = AppInstall(source = InstallationSource.OPEN_SOURCE, iconImageUrl = "https://example/icon")
+
+ assertThat(iconUrlBuilder.build(playStoreApp.source, playStoreApp.iconImageUrl)).isEqualTo("${CleanApkRetrofit.ASSET_URL}icon.png")
+ assertThat(iconUrlBuilder.build(pwaApp.source, pwaApp.iconImageUrl)).isEqualTo("${CleanApkRetrofit.ASSET_URL}icon2.png")
+ assertThat(iconUrlBuilder.build(fossApp.source, fossApp.iconImageUrl)).isEqualTo("https://example/icon")
}
}
diff --git a/app/src/test/java/foundation/e/apps/data/install/pkg/PwaManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/pkg/PwaManagerTest.kt
index 71a4485119267c426eb29ef5c51073ab1785f8b3..5485315c5fbc6834f70e7bb80eccbf8ba7d6206e 100644
--- a/app/src/test/java/foundation/e/apps/data/install/pkg/PwaManagerTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/install/pkg/PwaManagerTest.kt
@@ -24,6 +24,7 @@ import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
+import foundation.e.apps.data.install.AppInstallIconUrlBuilder
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -43,7 +44,7 @@ class PwaManagerTest {
addRow(arrayOf(null))
}
registerProvider(cursor)
- val manager = PwaManager(context(), mockk(relaxed = true))
+ val manager = PwaManager(context(), mockk(relaxed = true), AppInstallIconUrlBuilder())
val urls = manager.getInstalledPwaUrls()
@@ -56,7 +57,7 @@ class PwaManagerTest {
addRow(arrayOf("1"))
}
registerProvider(cursor)
- val manager = PwaManager(context(), mockk(relaxed = true))
+ val manager = PwaManager(context(), mockk(relaxed = true), AppInstallIconUrlBuilder())
val urls = manager.getInstalledPwaUrls()
diff --git a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt
index 59ccab3f21a96a8cc7c1e63dd773d3264c73e2ba..8ff3506f94025e02d4f3f002f5111ff95035fd00 100644
--- a/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/install/updates/UpdatesWorkerTest.kt
@@ -41,19 +41,18 @@ import foundation.e.apps.data.blockedApps.BlockedAppRepository
import foundation.e.apps.data.enums.FilterLevel
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.gitlab.SystemAppsUpdatesRepository
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
+import foundation.e.apps.data.install.core.AppInstallationFacade
import foundation.e.apps.data.updates.UpdatesManagerRepository
import foundation.e.apps.domain.model.LoginState
import foundation.e.apps.domain.model.User
+import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.domain.preferences.AppPreferencesRepository
import foundation.e.apps.domain.preferences.SessionRepository
import foundation.e.apps.login.PlayStoreAuthManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.spyk
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest
@@ -87,7 +86,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val appPreferencesRepository = createAppPreferencesRepository(
@@ -141,7 +140,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -163,7 +162,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val authData = AuthData(email = "user@example.com")
@@ -218,7 +217,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -229,7 +228,7 @@ class UpdatesWorkerTest {
verify(updatesManagerRepository, times(UpdatesWorker.MAX_RETRY_COUNT.plus(1))).getUpdates()
verify(playStoreAuthManager, times(UpdatesWorker.MAX_RETRY_COUNT.plus(1))).getValidatedAuthData()
verify(updatesManagerRepository, never()).getUpdatesOSS()
- verify(appInstallProcessor, never()).initAppInstall(any(), any())
+ verify(appInstallationFacade, never()).initAppInstall(any(), any())
}
@Test
@@ -239,7 +238,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val notificationManager = mock()
@@ -260,7 +259,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
),
@@ -287,7 +286,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -297,7 +296,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -313,7 +312,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val worker = createWorker(
@@ -322,7 +321,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -341,7 +340,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val worker = createWorker(
@@ -350,7 +349,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -389,7 +388,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -399,7 +398,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -421,7 +420,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val sessionRepository = mock()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -440,7 +439,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
sessionRepository,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository,
appPreferencesRepository
@@ -461,7 +460,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val sessionRepository = mock()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val appPreferencesRepository = createAppPreferencesRepository(
@@ -485,7 +484,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
sessionRepository,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository,
appPreferencesRepository
@@ -510,7 +509,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val sessionRepository = mock()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
val appPreferencesRepository = createAppPreferencesRepository(
@@ -560,7 +559,7 @@ class UpdatesWorkerTest {
)
whenever(systemAppsUpdatesRepository.fetchUpdatableSystemApps(true))
.thenReturn(ResultSupreme.Success(Unit))
- whenever(appInstallProcessor.initAppInstall(any(), any())).thenReturn(true)
+ whenever(appInstallationFacade.initAppInstall(any(), any())).thenReturn(true)
val worker = createWorker(
workerContext,
@@ -568,7 +567,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
sessionRepository,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository,
appPreferencesRepository
@@ -577,7 +576,7 @@ class UpdatesWorkerTest {
val result = worker.doWork()
assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success())
- verify(appInstallProcessor).initAppInstall(any(), any())
+ verify(appInstallationFacade).initAppInstall(any(), any())
}
@Test
@@ -592,7 +591,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val sessionRepository = mock()
val playStoreAuthManager = mock()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -644,7 +643,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
sessionRepository,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -652,7 +651,7 @@ class UpdatesWorkerTest {
val result = worker.doWork()
assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure())
- verify(appInstallProcessor, never()).initAppInstall(any(), any())
+ verify(appInstallationFacade, never()).initAppInstall(any(), any())
}
@@ -668,7 +667,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -696,7 +695,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -722,7 +721,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -750,7 +749,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -773,7 +772,7 @@ class UpdatesWorkerTest {
val playStoreAuthManager = createPlayStoreAuthManager(
ResultSupreme.Success(AuthData(email = "anon@example.com", isAnonymous = true))
)
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -783,14 +782,14 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
val paidApp = Application(name = "Paid", isFree = false)
val freeApp = Application(name = "Free", isFree = true)
- whenever(appInstallProcessor.initAppInstall(freeApp, true)).thenReturn(true)
+ whenever(appInstallationFacade.initAppInstall(freeApp, true)).thenReturn(true)
val result = worker.startUpdateProcess(listOf(paidApp, freeApp))
@@ -798,8 +797,8 @@ class UpdatesWorkerTest {
Pair(paidApp, false),
Pair(freeApp, true)
)
- verify(appInstallProcessor, times(1)).initAppInstall(freeApp, true)
- verify(appInstallProcessor, times(0)).initAppInstall(paidApp, true)
+ verify(appInstallationFacade, times(1)).initAppInstall(freeApp, true)
+ verify(appInstallationFacade, times(0)).initAppInstall(paidApp, true)
}
@Test
@@ -810,7 +809,7 @@ class UpdatesWorkerTest {
val appLoungeDataStore = createDataStore()
val authData = AuthData(email = "user@example.com", isAnonymous = false)
val playStoreAuthManager = createPlayStoreAuthManager(ResultSupreme.Success(authData))
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -820,18 +819,18 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
val paidApp = Application(name = "Paid", isFree = false)
- whenever(appInstallProcessor.initAppInstall(paidApp, true)).thenReturn(false)
+ whenever(appInstallationFacade.initAppInstall(paidApp, true)).thenReturn(false)
val result = worker.startUpdateProcess(listOf(paidApp))
assertThat(result).containsExactly(Pair(paidApp, false))
- verify(appInstallProcessor, times(1)).initAppInstall(paidApp, true)
+ verify(appInstallationFacade, times(1)).initAppInstall(paidApp, true)
}
@Test
@@ -841,7 +840,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -856,7 +855,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -889,7 +888,7 @@ class UpdatesWorkerTest {
val updatesManagerRepository = mock()
val appLoungeDataStore = createDataStore()
val playStoreAuthManager = createPlayStoreAuthManager()
- val appInstallProcessor = mock()
+ val appInstallationFacade = mock()
val blockedAppRepository = mock()
val systemAppsUpdatesRepository = mock()
@@ -904,7 +903,7 @@ class UpdatesWorkerTest {
updatesManagerRepository,
appLoungeDataStore,
playStoreAuthManager,
- appInstallProcessor,
+ appInstallationFacade,
blockedAppRepository,
systemAppsUpdatesRepository
)
@@ -944,7 +943,7 @@ class UpdatesWorkerTest {
updatesManagerRepository: UpdatesManagerRepository,
sessionRepository: SessionRepository,
playStoreAuthManager: PlayStoreAuthManager,
- appInstallProcessor: AppInstallProcessor,
+ appInstallationFacade: AppInstallationFacade,
blockedAppRepository: BlockedAppRepository,
systemAppsUpdatesRepository: SystemAppsUpdatesRepository,
appPreferencesRepository: AppPreferencesRepository = createAppPreferencesRepository(),
@@ -958,7 +957,7 @@ class UpdatesWorkerTest {
sessionRepository,
appPreferencesRepository,
playStoreAuthManager,
- appInstallProcessor
+ appInstallationFacade
)
}
diff --git a/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt
index feed1e14ecbaa796727a7b5fb452d40b08de2356..422ca6804894889e4344534a5ad9eb627e815168 100644
--- a/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/install/workmanager/InstallWorkManagerTest.kt
@@ -32,7 +32,7 @@ import androidx.work.impl.WorkManagerImpl
import androidx.work.impl.model.WorkSpec
import androidx.work.testing.WorkManagerTestInitHelper
import com.google.common.truth.Truth.assertThat
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import org.junit.After
import org.junit.Before
import org.junit.Test
diff --git a/app/src/test/java/foundation/e/apps/data/system/StorageComputerTest.kt b/app/src/test/java/foundation/e/apps/data/system/StorageComputerTest.kt
index 89d0da0fb638dce20293b7fa49617c50dd91ac0e..41cc78cc8d965b613b878d6ec3fcd8246bc093a6 100644
--- a/app/src/test/java/foundation/e/apps/data/system/StorageComputerTest.kt
+++ b/app/src/test/java/foundation/e/apps/data/system/StorageComputerTest.kt
@@ -30,7 +30,7 @@ class StorageComputerTest {
@Test
fun spaceMissing_matchesCalculatedDifference() {
- val appInstall = foundation.e.apps.data.install.models.AppInstall(appSize = 1234)
+ val appInstall = foundation.e.apps.data.installation.model.AppInstall(appSize = 1234)
val expected =
appInstall.appSize + (500 * (1000 * 1000)) - StorageComputer.calculateAvailableDiskSpace()
diff --git a/app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt
index 5499501d41470ae09f64a0975d56eef7c52727e5..73be10814a0100d997bae1c8e0536a6425492e83 100644
--- a/app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt
+++ b/app/src/test/java/foundation/e/apps/domain/ValidateAppAgeLimitUseCaseTest.kt
@@ -9,14 +9,16 @@ import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.blockedApps.BlockedAppRepository
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.enums.Type
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.install.toInstallationSource
+import foundation.e.apps.data.install.toInstallationType
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.parentalcontrol.ContentRatingDao
import foundation.e.apps.data.parentalcontrol.ParentalControlRepository
import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository
import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingGroup
import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository
+import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.domain.ValidateAppAgeLimitUseCase.Companion.KEY_ANTI_FEATURES_NSFW
import io.mockk.coEvery
import io.mockk.every
@@ -143,9 +145,9 @@ class ValidateAppAgeLimitUseCaseTest {
contentRating: ContentRating = ContentRating(id = "PG", title = "Parental Guidance")
) = AppInstall(
packageName = "pkg",
- source = source,
+ source = source.toInstallationSource(),
status = Status.UNAVAILABLE,
- type = Type.NATIVE,
+ type = Type.NATIVE.toInstallationType(),
contentRating = contentRating
)
}
diff --git a/app/src/test/java/foundation/e/apps/domain/install/InstallAppByIdUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/install/InstallAppByIdUseCaseTest.kt
index e6ea48f4bac9e2cd088cf8e672b713fa56be14d7..0224d9d446b1f710dac10de7bb1cf3019a2067bb 100644
--- a/app/src/test/java/foundation/e/apps/domain/install/InstallAppByIdUseCaseTest.kt
+++ b/app/src/test/java/foundation/e/apps/domain/install/InstallAppByIdUseCaseTest.kt
@@ -20,11 +20,11 @@ package foundation.e.apps.domain.install
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.install.core.AppInstallationFacade
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
import foundation.e.apps.domain.model.install.Status
import io.mockk.coEvery
import io.mockk.coVerify
@@ -42,7 +42,7 @@ class InstallAppByIdUseCaseTest {
private val getAppDetailsUseCase: GetAppDetailsUseCase = mockk()
private val appLoungePackageManager: AppLoungePackageManager = mockk()
- private val appInstallProcessor: AppInstallProcessor = mockk(relaxed = true)
+ private val appInstallationFacade: AppInstallationFacade = mockk(relaxed = true)
private val appInstallRepository: AppInstallRepository = mockk()
private val pwaManager: PwaManager = mockk()
@@ -53,7 +53,7 @@ class InstallAppByIdUseCaseTest {
useCase = InstallAppByIdUseCase(
getAppDetailsUseCase,
appLoungePackageManager,
- appInstallProcessor,
+ appInstallationFacade,
appInstallRepository,
pwaManager
)
@@ -67,7 +67,7 @@ class InstallAppByIdUseCaseTest {
assertEquals(Status.INSTALLED, result)
coVerify(exactly = 0) { getAppDetailsUseCase("pkg") }
- coVerify(exactly = 0) { appInstallProcessor.initAppInstall(any(), any()) }
+ coVerify(exactly = 0) { appInstallationFacade.initAppInstall(any(), any()) }
}
@Test
@@ -108,7 +108,7 @@ class InstallAppByIdUseCaseTest {
val result = useCase("pkg")
assertEquals(Status.INSTALLED, result)
- coVerify { appInstallProcessor.initAppInstall(app, isAnUpdate = false) }
+ coVerify { appInstallationFacade.initAppInstall(app, isAnUpdate = false) }
verify { pwaManager.getPwaStatus(app) }
}
@@ -123,7 +123,7 @@ class InstallAppByIdUseCaseTest {
val result = useCase("pkg")
assertEquals(Status.INSTALLATION_ISSUE, result)
- coVerify { appInstallProcessor.initAppInstall(app, isAnUpdate = false) }
+ coVerify { appInstallationFacade.initAppInstall(app, isAnUpdate = false) }
}
@Test
@@ -141,7 +141,7 @@ class InstallAppByIdUseCaseTest {
val result = useCase("pkg")
assertEquals(Status.BLOCKED, result)
- coVerify { appInstallProcessor.initAppInstall(app, isAnUpdate = false) }
+ coVerify { appInstallationFacade.initAppInstall(app, isAnUpdate = false) }
}
private fun applicationFixture(id: String, packageName: String, source: Source): Application {
diff --git a/app/src/test/java/foundation/e/apps/fusedManager/AppManagerWrapperTest.kt b/app/src/test/java/foundation/e/apps/fusedManager/AppManagerWrapperTest.kt
index fa0980eab23867263ba5324c515e83be0f7632bc..f4b50cb01e9108ac64e79712741240eb10ee319f 100644
--- a/app/src/test/java/foundation/e/apps/fusedManager/AppManagerWrapperTest.kt
+++ b/app/src/test/java/foundation/e/apps/fusedManager/AppManagerWrapperTest.kt
@@ -23,7 +23,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.fdroid.FDroidRepository
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.install.workmanager.InstallWorkManager
import foundation.e.apps.installProcessor.FakeAppInstallDAO
import foundation.e.apps.util.MainCoroutineRule
diff --git a/app/src/test/java/foundation/e/apps/fusedManager/FakeAppManager.kt b/app/src/test/java/foundation/e/apps/fusedManager/FakeAppManager.kt
index d30c5555eae41711ad56e307f999ce2ad27af084..e76afd006baf23761fe3d19ca769214adbcb5ea9 100644
--- a/app/src/test/java/foundation/e/apps/fusedManager/FakeAppManager.kt
+++ b/app/src/test/java/foundation/e/apps/fusedManager/FakeAppManager.kt
@@ -19,10 +19,10 @@
package foundation.e.apps.fusedManager
import androidx.lifecycle.LiveData
-import foundation.e.apps.domain.model.install.Status
-import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.AppManager
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.local.AppInstallDAO
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.domain.model.install.Status
import java.io.File
class FakeAppManager(private val appInstallDAO: AppInstallDAO) : AppManager {
diff --git a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt
index c4bbbff14c4e51cc1add0ceae0e98fbe76be06db..cb28f4b0e0f26b1c7729b4c9cb28361c0a7ac521 100644
--- a/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt
+++ b/app/src/test/java/foundation/e/apps/install/workmanager/InstallOrchestratorTest.kt
@@ -31,9 +31,9 @@ import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.test.core.app.ApplicationProvider
import com.google.common.util.concurrent.Futures
-import foundation.e.apps.data.install.AppInstallDAO
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.repository.AppInstallRepository
import foundation.e.apps.data.install.workmanager.InstallOrchestrator
import foundation.e.apps.data.install.workmanager.InstallWorkManager
import foundation.e.apps.domain.model.install.Status
@@ -73,7 +73,7 @@ class InstallOrchestratorTest {
val mainCoroutineRule = MainCoroutineRule()
private val context: Context = ApplicationProvider.getApplicationContext()
- private val installDao = mock()
+ private val appInstallRepository = mock()
private val appManagerWrapper = mock()
private var isInstallWorkManagerMocked = false
@@ -95,10 +95,10 @@ class InstallOrchestratorTest {
fun init_marksStaleDownloadAsInstallationIssue() = runTest {
val app = createAppInstall(status = Status.DOWNLOADING)
- whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(false)
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -111,10 +111,10 @@ class InstallOrchestratorTest {
fun init_updatesStatusToInstalledWhenAppAlreadyInstalled() = runTest {
val app = createAppInstall(status = Status.DOWNLOADED)
- whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
whenever(appManagerWrapper.isFusedDownloadInstalled(app)).thenReturn(true)
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -137,9 +137,9 @@ class InstallOrchestratorTest {
whenever(sessionInfo.isActive).thenReturn(true)
whenever(packageManager.packageInstaller).thenReturn(packageInstaller)
whenever(packageInstaller.allSessions).thenReturn(listOf(sessionInfo))
- whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
- val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(wrappedContext, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -153,9 +153,9 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
mockInstallWorkManagerSuccess()
- whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -169,10 +169,10 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
mockInstallWorkManagerSuccess()
- whenever(installDao.getDownloads())
+ whenever(appInstallRepository.getDownloads())
.thenReturn(flowOf(emptyList()), flowOf(listOf(active, awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -185,9 +185,9 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting", status = Status.AWAITING)
mockInstallWorkManagerFailure()
- whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -200,9 +200,9 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting.cancelled", status = Status.AWAITING)
mockInstallWorkManagerCancellation()
- whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -219,9 +219,9 @@ class InstallOrchestratorTest {
.build()
WorkManager.getInstance(context).enqueue(request).result.get()
- whenever(installDao.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(listOf(app)), emptyFlow())
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -236,9 +236,9 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting.success", status = Status.AWAITING)
mockInstallWorkManagerSuccess()
- whenever(installDao.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
+ whenever(appInstallRepository.getDownloads()).thenReturn(flowOf(emptyList()), flowOf(listOf(awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -251,11 +251,11 @@ class InstallOrchestratorTest {
val awaiting = createAppInstall(id = "app.awaiting.after.exception", status = Status.AWAITING)
mockInstallWorkManagerSuccess()
- whenever(installDao.getDownloads())
+ whenever(appInstallRepository.getDownloads())
.thenThrow(RuntimeException("reconcile failed"))
.thenReturn(flowOf(listOf(awaiting)))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
@@ -265,14 +265,14 @@ class InstallOrchestratorTest {
@Test
fun init_stopsAfterCancellationExceptionDuringReconciliation() = runTest {
- whenever(installDao.getDownloads()).thenThrow(CancellationException("cancel reconcile"))
+ whenever(appInstallRepository.getDownloads()).thenThrow(CancellationException("cancel reconcile"))
- val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, installDao)
+ val installOrchestrator = InstallOrchestrator(context, this, appManagerWrapper, appInstallRepository)
installOrchestrator.init()
advanceUntilIdle()
- verifyMockito(installDao, times(1)).getDownloads()
+ verifyMockito(appInstallRepository, times(1)).getDownloads()
}
private fun mockInstallWorkManagerSuccess() {
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AgeLimiterTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AgeLimiterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7a4278f4bf119c6f4c481dcfce242286d41bfc89
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AgeLimiterTest.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.installation.model.AppInstall
+import foundation.e.apps.data.install.core.helper.AgeLimiter
+import foundation.e.apps.data.installation.port.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 AgeLimiterTest {
+ private lateinit var validateAppAgeLimitUseCase: ValidateAppAgeLimitUseCase
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var parentalControlAuthGateway: ParentalControlAuthGateway
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var gate: AgeLimiter
+
+ @Before
+ fun setup() {
+ validateAppAgeLimitUseCase = Mockito.mock(ValidateAppAgeLimitUseCase::class.java)
+ appManagerWrapper = mockk(relaxed = true)
+ parentalControlAuthGateway = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher(autoCompleteDeferred = true)
+ gate = AgeLimiter(
+ validateAppAgeLimitUseCase,
+ appManagerWrapper,
+ appEventDispatcher,
+ parentalControlAuthGateway
+ )
+ }
+
+ @Test
+ fun allow_returnsTrueWhenAgeRatingIsValid() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
+
+ val result = gate.allow(appInstall)
+
+ assertTrue(result)
+ coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
+ }
+
+ @Test
+ fun allow_returnsFalseAndCancelsWhenAgeRatingInvalidWithoutPin() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false)))
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+
+ @Test
+ fun allow_returnsTrueWhenPinIsRequiredAndAuthenticationSucceeds() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(
+ ResultSupreme.create(
+ ResultStatus.OK,
+ ContentRatingValidity(false, requestPin = true)
+ )
+ )
+ coEvery { parentalControlAuthGateway.awaitAuthentication() } returns true
+
+ val result = gate.allow(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AgeLimitRestrictionEvent })
+ coVerify(exactly = 0) { appManagerWrapper.cancelDownload(any()) }
+ }
+
+ @Test
+ fun allow_returnsFalseWhenPinIsRequiredAndAuthenticationFails() = runTest {
+ val appInstall = AppInstall(id = "123", name = "App", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(
+ ResultSupreme.create(
+ ResultStatus.OK,
+ ContentRatingValidity(false, requestPin = true)
+ )
+ )
+ coEvery { parentalControlAuthGateway.awaitAuthentication() } returns false
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+
+ @Test
+ fun allow_dispatchesErrorDialogWhenValidationFails() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ Mockito.`when`(validateAppAgeLimitUseCase(appInstall))
+ .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false)))
+
+ val result = gate.allow(appInstall)
+
+ assertFalse(result)
+ val errorDialogEvent = appEventDispatcher.events.last() as AppEvent.ErrorMessageDialogEvent
+ assertEquals(R.string.data_load_error_desc, errorDialogEvent.data)
+ coVerify { appManagerWrapper.cancelDownload(appInstall) }
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt
deleted file mode 100644
index 84e01ea3c8dcabc352a96cc542741d5af9ad02c4..0000000000000000000000000000000000000000
--- a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallProcessorTest.kt
+++ /dev/null
@@ -1,716 +0,0 @@
-/*
- * Copyright MURENA SAS 2023
- * Apps Quickly and easily install Android apps onto your device!
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package foundation.e.apps.installProcessor
-
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.Network
-import android.net.NetworkCapabilities
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import androidx.work.Operation
-import com.google.common.util.concurrent.Futures
-import foundation.e.apps.data.ResultSupreme
-import foundation.e.apps.data.application.ApplicationRepository
-import foundation.e.apps.data.enums.ResultStatus
-import foundation.e.apps.data.application.data.Application
-import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.enums.Type
-import foundation.e.apps.domain.model.install.Status
-import foundation.e.apps.data.fdroid.FDroidRepository
-import foundation.e.apps.data.install.AppInstallComponents
-import foundation.e.apps.data.install.AppInstallRepository
-import foundation.e.apps.data.install.AppManager
-import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
-import foundation.e.apps.data.install.notification.StorageNotificationManager
-import foundation.e.apps.data.install.workmanager.AppInstallProcessor
-import foundation.e.apps.data.preference.PlayStoreAuthStore
-import foundation.e.apps.data.install.workmanager.InstallWorkManager
-import foundation.e.apps.data.system.StorageComputer
-import foundation.e.apps.domain.ValidateAppAgeLimitUseCase
-import foundation.e.apps.domain.model.ContentRatingValidity
-import foundation.e.apps.domain.preferences.SessionRepository
-import foundation.e.apps.util.MainCoroutineRule
-import io.mockk.every
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.verify
-import io.mockk.mockk
-import io.mockk.mockkObject
-import io.mockk.unmockkObject
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.After
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.mockito.Mock
-import org.mockito.Mockito
-import org.mockito.MockitoAnnotations
-import org.mockito.kotlin.mock
-import org.mockito.kotlin.whenever
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class AppInstallProcessorTest {
- // Run tasks synchronously
- @Rule
- @JvmField
- val instantExecutorRule = InstantTaskExecutorRule()
-
- // Sets the main coroutines dispatcher to a TestCoroutineScope for unit testing.
- @ExperimentalCoroutinesApi
- @get:Rule
- var mainCoroutineRule = MainCoroutineRule()
-
- private lateinit var fakeFusedDownloadDAO: FakeAppInstallDAO
- private lateinit var appInstallRepository: AppInstallRepository
- private lateinit var fakeFusedManagerRepository: FakeAppManagerWrapper
-
- @Mock
- private lateinit var fakeFusedManager: AppManager
-
- @Mock
- private lateinit var fakeFDroidRepository: FDroidRepository
-
- @Mock
- private lateinit var context: Context
-
- @Mock
- private lateinit var sessionRepository: SessionRepository
-
- @Mock
- private lateinit var playStoreAuthStore: PlayStoreAuthStore
-
- @Mock
- private lateinit var applicationRepository: ApplicationRepository
-
- private lateinit var appInstallProcessor: AppInstallProcessor
-
- @Mock
- private lateinit var validateAppAgeRatingUseCase: ValidateAppAgeLimitUseCase
-
- @Mock
- private lateinit var storageNotificationManager: StorageNotificationManager
-
- private var isInstallWorkManagerMocked = false
-
- @Before
- fun setup() {
- MockitoAnnotations.openMocks(this)
- fakeFusedDownloadDAO = FakeAppInstallDAO()
- appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO)
- fakeFusedManagerRepository =
- FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository)
- val appInstallComponents =
- AppInstallComponents(appInstallRepository, fakeFusedManagerRepository)
-
- appInstallProcessor = AppInstallProcessor(
- context,
- appInstallComponents,
- applicationRepository,
- validateAppAgeRatingUseCase,
- sessionRepository,
- playStoreAuthStore,
- storageNotificationManager
- )
- }
-
- @After
- fun teardown() {
- if (isInstallWorkManagerMocked) {
- unmockkObject(InstallWorkManager)
- isInstallWorkManagerMocked = false
- }
- }
-
- @Test
- fun processInstallTest() = runTest {
- val fusedDownload = initTest()
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertTrue("processInstall", finalFusedDownload == null)
- }
-
- private suspend fun initTest(
- packageName: String? = null,
- downloadUrlList: MutableList? = null
- ): AppInstall {
- val fusedDownload = createFusedDownload(packageName, downloadUrlList)
- fakeFusedDownloadDAO.addDownload(fusedDownload)
- return fusedDownload
- }
-
- @Test
- fun `processInstallTest when FusedDownload is already failed`() = runTest {
- val fusedDownload = initTest()
- fusedDownload.status = Status.BLOCKED
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", Status.BLOCKED, finalFusedDownload?.status)
- }
-
- @Test
- fun `processInstallTest when files are downloaded but not installed`() = runTest {
- val fusedDownload = initTest()
- fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertTrue("processInstall", finalFusedDownload == null)
- }
-
- @Test
- fun `processInstallTest when packageName is empty and files are downloaded`() = runTest {
- val fusedDownload = initTest(packageName = "")
- fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
- }
-
- @Test
- fun `processInstallTest when downloadUrls are not available`() = runTest {
- val fusedDownload = initTest(downloadUrlList = mutableListOf())
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
- }
-
- @Test
- fun `processInstallTest when exception is occurred`() = runTest {
- val fusedDownload = initTest()
- fakeFusedManagerRepository.forceCrash = true
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertTrue(
- "processInstall",
- finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE
- )
- }
-
- @Test
- fun `processInstallTest when download is failed`() = runTest {
- val fusedDownload = initTest()
- fakeFusedManagerRepository.willDownloadFail = true
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
- }
-
- @Test
- fun `processInstallTest when install is failed`() = runTest {
- val fusedDownload = initTest()
- fakeFusedManagerRepository.willInstallFail = true
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
- }
-
- @Test
- fun `processInstallTest when age limit is satisfied`() = runTest {
- val fusedDownload = initTest()
- Mockito.`when`(validateAppAgeRatingUseCase(fusedDownload))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", finalFusedDownload, null)
- }
-
- @Test
- fun `processInstallTest when age limit is not satisfied`() = runTest {
- val fusedDownload = initTest()
- Mockito.`when`(validateAppAgeRatingUseCase(fusedDownload))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(false)))
-
- val finalFusedDownload = runProcessInstall(fusedDownload)
- assertEquals("processInstall", finalFusedDownload, null)
- }
-
- @Test
- fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest {
- val appInstall = AppInstall(
- type = Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- every { StorageComputer.spaceMissing(appInstall) } returns 0
-
- val result = processor.canEnqueue(appInstall)
-
- assertTrue(result)
- coVerify { appManagerWrapper.addDownload(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest {
- val appInstall = AppInstall(
- type = Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkUnavailable()
- every { StorageComputer.spaceMissing(appInstall) } returns 0
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- coVerify { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun canEnqueue_returnsFalseWhenStorageMissing() = runTest {
- val appInstall = AppInstall(
- type = Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- every { StorageComputer.spaceMissing(appInstall) } returns 100L
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- Mockito.verify(storageNotificationManager).showNotEnoughSpaceNotification(appInstall)
- coVerify { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest {
- val appInstall = AppInstall(
- type = Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns false
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- everyNetworkAvailable()
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun canEnqueue_returnsFalseWhenAgeLimitInvalid() = runTest {
- val appInstall = AppInstall(
- type = Type.PWA,
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("apk"),
- packageName = "com.example.app"
- )
-
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockkObject(StorageComputer)
- try {
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.UNKNOWN, ContentRatingValidity(false)))
- everyNetworkAvailable()
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.canEnqueue(appInstall)
-
- assertEquals(false, result)
- coVerify { appManagerWrapper.cancelDownload(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun enqueueFusedDownload_returnsTrueAndEnqueuesWorkForUpdate() = runTest {
- val appInstall = createEnqueueAppInstall()
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerSuccess()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true)
-
- assertTrue(result)
- coVerify { appManagerWrapper.updateAwaiting(appInstall) }
- verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun enqueueFusedDownload_returnsFalseAndMarksIssueWhenUpdateEnqueueFails() = runTest {
- val appInstall = createEnqueueAppInstall()
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerFailure()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = true, isSystemApp = true)
-
- assertEquals(false, result)
- coVerify { appManagerWrapper.updateAwaiting(appInstall) }
- coVerify { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun enqueueFusedDownload_returnsTrueWithoutEnqueueingWorkForRegularInstall() = runTest {
- val appInstall = createEnqueueAppInstall()
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerSuccess()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true)
-
- assertTrue(result)
- coVerify { appManagerWrapper.updateAwaiting(appInstall) }
- verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun enqueueFusedDownload_skipsWorkManagerFailurePathForRegularInstall() = runTest {
- val appInstall = createEnqueueAppInstall()
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerFailure()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.enqueueFusedDownload(appInstall, isAnUpdate = false, isSystemApp = true)
-
- assertTrue(result)
- verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) }
- coVerify(exactly = 0) { appManagerWrapper.installationIssue(appInstall) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun initAppInstall_enqueuesUpdateWorkWhenExplicitFlagIsTrue() = runTest {
- val application = createApplication(status = Status.INSTALLED)
- val appInstall = createExpectedAppInstall(application)
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerSuccess()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.initAppInstall(application, isAnUpdate = true)
-
- assertTrue(result)
- verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun initAppInstall_enqueuesUpdateWorkWhenApplicationIsUpdatable() = runTest {
- val application = createApplication(status = Status.UPDATABLE)
- val appInstall = createExpectedAppInstall(application)
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerSuccess()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.initAppInstall(application, isAnUpdate = false)
-
- assertTrue(result)
- verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun initAppInstall_enqueuesUpdateWorkWhenAppIsAlreadyInstalled() = runTest {
- val application = createApplication(status = Status.INSTALLED)
- val appInstall = createExpectedAppInstall(application)
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerSuccess()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns true
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.initAppInstall(application, isAnUpdate = false)
-
- assertTrue(result)
- verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- @Test
- fun initAppInstall_doesNotEnqueueWorkWhenInstallIsNotAnUpdate() = runTest {
- val application = createApplication(status = Status.INSTALLED)
- val appInstall = createExpectedAppInstall(application)
- val appManagerWrapper = mockk(relaxed = true)
- val processor = createProcessorForCanEnqueue(appManagerWrapper)
-
- mockInstallWorkManagerFailure()
- mockkObject(StorageComputer)
- try {
- everyNetworkAvailable()
- Mockito.`when`(validateAppAgeRatingUseCase(appInstall))
- .thenReturn(ResultSupreme.create(ResultStatus.OK, ContentRatingValidity(true)))
- every { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false
- coEvery { appManagerWrapper.addDownload(appInstall) } returns true
- every { StorageComputer.spaceMissing(appInstall) } returns 0L
-
- val result = processor.initAppInstall(application, isAnUpdate = false)
-
- assertTrue(result)
- verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) }
- } finally {
- unmockkObject(StorageComputer)
- }
- }
-
- private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? {
- appInstallProcessor.processInstall(appInstall.id, false) {
- // _ignored_
- }
- return fakeFusedDownloadDAO.getDownloadById(appInstall.id)
- }
-
- private fun createFusedDownload(
- packageName: String? = null,
- downloadUrlList: MutableList? = null
- ) = AppInstall(
- id = "121",
- status = Status.AWAITING,
- downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"),
- packageName = packageName ?: "com.unit.test"
- )
-
- private fun createProcessorForCanEnqueue(
- appManagerWrapper: AppManagerWrapper
- ): AppInstallProcessor {
- val appInstallRepository = AppInstallRepository(FakeAppInstallDAO())
- val appInstallComponents = AppInstallComponents(appInstallRepository, appManagerWrapper)
- return AppInstallProcessor(
- context,
- appInstallComponents,
- applicationRepository,
- validateAppAgeRatingUseCase,
- sessionRepository,
- playStoreAuthStore,
- storageNotificationManager
- )
- }
-
- private fun createEnqueueAppInstall() = AppInstall(
- id = "123",
- status = Status.AWAITING,
- downloadURLList = mutableListOf("https://example.org/app.apk"),
- packageName = "com.example.app",
- type = Type.PWA,
- source = Source.PWA
- )
-
- private fun createApplication(status: Status) = Application(
- _id = "123",
- name = "Test app",
- package_name = "com.example.app",
- status = status,
- source = Source.PWA,
- type = Type.PWA,
- latest_version_code = 1L,
- isFree = true,
- isSystemApp = true,
- url = "https://example.org/app.apk"
- )
-
- private fun createExpectedAppInstall(application: Application) = AppInstall(
- application._id,
- application.source,
- application.status,
- application.name,
- application.package_name,
- mutableListOf(),
- mutableMapOf(),
- application.status,
- application.type,
- application.icon_image_path,
- application.latest_version_code,
- application.offer_type,
- application.isFree,
- application.originalSize
- ).also {
- it.contentRating = application.contentRating
- if (it.type == Type.PWA || application.source == Source.SYSTEM_APP) {
- it.downloadURLList = mutableListOf(application.url)
- }
- }
-
- private fun mockInstallWorkManagerSuccess() {
- mockkObject(InstallWorkManager)
- isInstallWorkManagerMocked = true
- every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() }
- every { InstallWorkManager.enqueueWork(any(), any(), any()) } returns successfulOperation()
- }
-
- private fun mockInstallWorkManagerFailure() {
- mockkObject(InstallWorkManager)
- isInstallWorkManagerMocked = true
- every { InstallWorkManager.getUniqueWorkName(any()) } answers { callOriginal() }
- every { InstallWorkManager.enqueueWork(any(), any(), any()) } throws RuntimeException("enqueue failed")
- }
-
- private fun successfulOperation(): Operation {
- val operation = mock()
- whenever(operation.result).thenReturn(Futures.immediateFuture(Operation.SUCCESS))
- return operation
- }
-
- private fun everyNetworkAvailable() {
- val connectivityManager = mock()
- val network = mock()
- val networkCapabilities = mock()
- whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager)
- whenever(connectivityManager.activeNetwork).thenReturn(network)
- whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities)
- whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)).thenReturn(true)
- whenever(networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).thenReturn(true)
- }
-
- private fun everyNetworkUnavailable() {
- val connectivityManager = mock()
- val network = mock()
- whenever(context.getSystemService(ConnectivityManager::class.java)).thenReturn(connectivityManager)
- whenever(connectivityManager.activeNetwork).thenReturn(network)
- whenever(connectivityManager.getNetworkCapabilities(network)).thenReturn(null)
- }
-}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6f6af2a56748f7522cf9cbb298b8ac3d80dc1c68
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/AppInstallationFacadeTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright MURENA SAS 2026
+ * Apps Quickly and easily install Android apps onto your device!
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package foundation.e.apps.installProcessor
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.install.core.AppInstallationFacade
+import foundation.e.apps.data.install.core.InstallationEnqueuer
+import foundation.e.apps.data.install.core.InstallationRequest
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationResult
+import foundation.e.apps.data.installation.core.InstallationProcessor
+import foundation.e.apps.domain.model.install.Status
+import foundation.e.apps.util.MainCoroutineRule
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppInstallationFacadeTest {
+ @Rule
+ @JvmField
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ var mainCoroutineRule = MainCoroutineRule()
+
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var appInstallationFacade: AppInstallationFacade
+ private lateinit var installationRequest: InstallationRequest
+ private lateinit var installationEnqueuer: InstallationEnqueuer
+ private lateinit var installationProcessor: InstallationProcessor
+
+ @Before
+ fun setup() {
+ appManagerWrapper = mockk(relaxed = true)
+ installationRequest = mockk(relaxed = true)
+ installationEnqueuer = mockk(relaxed = true)
+ installationProcessor = mockk(relaxed = true)
+
+ appInstallationFacade = AppInstallationFacade(
+ appManagerWrapper,
+ installationEnqueuer,
+ installationProcessor,
+ installationRequest
+ )
+ }
+
+ @Test
+ fun initAppInstall_computesUpdateFlagAndDelegates() = runTest {
+ val application = Application(
+ _id = "123",
+ source = Source.PLAY_STORE,
+ status = Status.UPDATABLE,
+ name = "Example",
+ package_name = "com.example.app",
+ type = Type.NATIVE
+ )
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ coEvery { installationRequest.create(application) } returns appInstall
+ coEvery { appManagerWrapper.isFusedDownloadInstalled(appInstall) } returns false
+ coEvery {
+ installationEnqueuer.enqueue(
+ appInstall,
+ true,
+ application.isSystemApp
+ )
+ } returns true
+
+ val result = appInstallationFacade.initAppInstall(application)
+
+ assertTrue(result)
+ coVerify { installationRequest.create(application) }
+ coVerify { installationEnqueuer.enqueue(appInstall, true, application.isSystemApp) }
+ }
+
+ @Test
+ fun enqueueFusedDownload_delegatesResult() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ coEvery { installationEnqueuer.enqueue(appInstall, true, true) } returns false
+
+ val result = appInstallationFacade.enqueueFusedDownload(appInstall, true, true)
+
+ assertEquals(false, result)
+ coVerify { installationEnqueuer.enqueue(appInstall, true, true) }
+ }
+
+ @Test
+ fun processInstall_delegatesResult() = runTest {
+ coEvery {
+ installationProcessor.processInstall("123", false, any())
+ } returns Result.success(InstallationResult.OK)
+
+ val result = appInstallationFacade.processInstall("123", false) {
+ // _ignored_
+ }
+
+ assertEquals(ResultStatus.OK, result.getOrNull())
+ coVerify { installationProcessor.processInstall("123", false, any()) }
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a4a23d1d87be9fe9e2df9dcd696a2a419b38c49c
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/DevicePreconditionsTest.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import foundation.e.apps.R
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.install.notification.StorageNotificationManager
+import foundation.e.apps.data.install.core.helper.DevicePreconditions
+import foundation.e.apps.data.installation.port.NetworkStatusChecker
+import foundation.e.apps.data.installation.port.StorageSpaceChecker
+import foundation.e.apps.domain.model.install.Status
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DevicePreconditionsTest {
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var storageNotificationManager: StorageNotificationManager
+ private lateinit var storageSpaceChecker: StorageSpaceChecker
+ private lateinit var networkStatusChecker: NetworkStatusChecker
+ private lateinit var preconditions: DevicePreconditions
+
+ @Before
+ fun setup() {
+ appManagerWrapper = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher()
+ storageNotificationManager = mockk(relaxed = true)
+ storageSpaceChecker = mockk(relaxed = true)
+ networkStatusChecker = mockk(relaxed = true)
+ preconditions = DevicePreconditions(
+ appManagerWrapper,
+ appEventDispatcher,
+ storageNotificationManager,
+ storageSpaceChecker,
+ networkStatusChecker
+ )
+ }
+
+ @Test
+ fun canProceed_returnsFalseWhenNetworkUnavailable() = runTest {
+ val appInstall = createInstall()
+ every { networkStatusChecker.isNetworkAvailable() } returns false
+
+ val result = preconditions.canProceed(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ verify(exactly = 0) { storageSpaceChecker.spaceMissing(any()) }
+ assertTrue(appEventDispatcher.events.any {
+ it is AppEvent.NoInternetEvent && it.data == false
+ })
+ }
+
+ @Test
+ fun canProceed_returnsFalseWhenStorageIsMissing() = runTest {
+ val appInstall = createInstall()
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 512L
+
+ val result = preconditions.canProceed(appInstall)
+
+ assertFalse(result)
+ verify { storageNotificationManager.showNotEnoughSpaceNotification(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ assertTrue(appEventDispatcher.events.any {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.not_enough_storage
+ })
+ }
+
+ @Test
+ fun canProceed_returnsTrueWhenNetworkAndStorageChecksPass() = runTest {
+ val appInstall = createInstall()
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = preconditions.canProceed(appInstall)
+
+ assertTrue(result)
+ coVerify(exactly = 0) { appManagerWrapper.installationIssue(any()) }
+ verify(exactly = 0) { storageNotificationManager.showNotEnoughSpaceNotification(any()) }
+ assertTrue(appEventDispatcher.events.isEmpty())
+ }
+
+ private fun createInstall() = AppInstall(
+ id = "123",
+ status = Status.AWAITING,
+ name = "Example App",
+ packageName = "com.example.app",
+ appSize = 1024L
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..47861c95368b955b0c345451deab5a33d79ae394
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/DownloadUrlRefresherTest.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import com.aurora.gplayapi.exceptions.InternalException
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
+import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
+import foundation.e.apps.domain.model.install.Status
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import kotlin.test.assertFailsWith
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DownloadUrlRefresherTest {
+ private lateinit var applicationRepository: ApplicationRepository
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var appManager: AppManager
+ private lateinit var refresher: DownloadUrlRefresher
+
+ @Before
+ fun setup() {
+ applicationRepository = mockk(relaxed = true)
+ appInstallRepository = mockk(relaxed = true)
+ appManagerWrapper = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher()
+ appManager = mockk(relaxed = true)
+ refresher = DownloadUrlRefresher(
+ applicationRepository,
+ appInstallRepository,
+ appManagerWrapper,
+ appEventDispatcher,
+ appManager
+ )
+ }
+
+ @Test
+ fun updateDownloadUrls_returnsTrueWhenRefreshSucceeds() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } returns Unit
+
+ val result = refresher.updateDownloadUrls(appInstall, false)
+
+ assertTrue(result)
+ coVerify(exactly = 0) { appManagerWrapper.installationIssue(any()) }
+ }
+
+ @Test
+ fun updateDownloadUrls_handlesFreeAppNotPurchasedAsRestricted() = runTest {
+ val appInstall = createNativeInstall(isFree = true)
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws InternalException.AppNotPurchased()
+
+ val result = refresher.updateDownloadUrls(appInstall, false)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppRestrictedOrUnavailable })
+ coVerify { appManager.addDownload(appInstall) }
+ coVerify { appManager.updateUnavailable(appInstall) }
+ coVerify(exactly = 0) { appManagerWrapper.addFusedDownloadPurchaseNeeded(any()) }
+ }
+
+ @Test
+ fun updateDownloadUrls_handlesPaidAppNotPurchasedAsPurchaseNeeded() = runTest {
+ val appInstall = createNativeInstall(isFree = false)
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws InternalException.AppNotPurchased()
+
+ val result = refresher.updateDownloadUrls(appInstall, false)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) }
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppPurchaseEvent })
+ coVerify(exactly = 0) { appManager.addDownload(any()) }
+ }
+
+ @Test
+ fun updateDownloadUrls_recordsIssueWhenHttpRefreshFails() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws GplayHttpRequestException(403, "forbidden")
+
+ val result = refresher.updateDownloadUrls(appInstall, false)
+
+ assertFalse(result)
+ coVerify { appInstallRepository.addDownload(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ assertTrue(appEventDispatcher.events.none { it is AppEvent.UpdateEvent })
+ }
+
+ @Test
+ fun updateDownloadUrls_dispatchesUpdateEventWhenUpdateRefreshFails() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws IllegalStateException("boom")
+
+ val result = refresher.updateDownloadUrls(appInstall, true)
+
+ assertFalse(result)
+ coVerify { appInstallRepository.addDownload(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent })
+ }
+
+ @Test
+ fun updateDownloadUrls_doesNotDuplicateExistingDownloadOnFailure() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns appInstall
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws IllegalStateException("boom")
+
+ val result = refresher.updateDownloadUrls(appInstall, false)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { appInstallRepository.addDownload(any()) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun updateDownloadUrls_rethrowsCancellation() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)
+ } throws CancellationException("cancelled")
+
+ assertFailsWith {
+ refresher.updateDownloadUrls(appInstall, false)
+ }
+ }
+
+ private fun createNativeInstall(isFree: Boolean = true) = AppInstall(
+ type = InstallationType.NATIVE,
+ source = InstallationSource.PLAY_STORE,
+ id = "123",
+ status = Status.AWAITING,
+ packageName = "com.example.app",
+ isFree = isFree
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b9ff56e859e6b38f557bd673a4d99c28961beef6
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppEventDispatcher.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.wrapper.AppEventDispatcher
+
+internal class FakeAppEventDispatcher(
+ private val autoCompleteDeferred: Boolean = false
+) : AppEventDispatcher {
+ val events = mutableListOf()
+
+ override suspend fun dispatch(event: AppEvent) {
+ events.add(event)
+ if (autoCompleteDeferred && event is AppEvent.AgeLimitRestrictionEvent) {
+ event.onClose?.complete(Unit)
+ }
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt
index 3a76f2e0bf6f59b1793ee2c53757e221191640ca..b8cd526fde4380cd049f83039fe1034a53a0f133 100644
--- a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt
+++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppInstallDAO.kt
@@ -20,9 +20,9 @@ package foundation.e.apps.installProcessor
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
+import foundation.e.apps.data.installation.local.AppInstallDAO
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.model.install.Status
-import foundation.e.apps.data.install.AppInstallDAO
-import foundation.e.apps.data.install.models.AppInstall
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManagerWrapper.kt b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManagerWrapper.kt
index a046570b3be12795eee76acb00f48d9b4ea1c0b7..0a656e7c6ec3e1df11f1e6743b6d05154c0b6ab7 100644
--- a/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManagerWrapper.kt
+++ b/app/src/test/java/foundation/e/apps/installProcessor/FakeAppManagerWrapper.kt
@@ -23,7 +23,7 @@ import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.fdroid.FDroidRepository
import foundation.e.apps.data.install.AppManagerWrapper
import foundation.e.apps.data.install.AppManager
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import kotlinx.coroutines.delay
class FakeAppManagerWrapper(
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..953424eb5aae7b7fae24e2bd5d3165923dae8108
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallAppWorkerTest.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.Data
+import com.google.common.truth.Truth.assertThat
+import foundation.e.apps.data.enums.ResultStatus
+import foundation.e.apps.data.install.core.AppInstallationFacade
+import foundation.e.apps.data.install.workmanager.InstallAppWorker
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.R])
+@OptIn(ExperimentalCoroutinesApi::class)
+class InstallAppWorkerTest {
+ private lateinit var appInstallationFacade: AppInstallationFacade
+
+ @Before
+ fun setup() {
+ appInstallationFacade = mockk(relaxed = true)
+ }
+
+ @Test
+ fun doWork_returnsFailureWhenFusedDownloadIdIsMissing() = runTest {
+ val worker = createWorker(Data.EMPTY)
+
+ val result = worker.doWork()
+
+ assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure())
+ coVerify(exactly = 0) { appInstallationFacade.processInstall(any(), any(), any()) }
+ }
+
+ @Test
+ fun doWork_returnsSuccessWhenProcessorSucceeds() = runTest {
+ coEvery {
+ appInstallationFacade.processInstall("123", true, any())
+ } returns Result.success(ResultStatus.OK)
+ val worker = createWorker(
+ Data.Builder()
+ .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, "123")
+ .putBoolean(InstallAppWorker.IS_UPDATE_WORK, true)
+ .build()
+ )
+
+ val result = worker.doWork()
+
+ assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.success())
+ coVerify { appInstallationFacade.processInstall("123", true, any()) }
+ }
+
+ @Test
+ fun doWork_returnsFailureWhenProcessorFails() = runTest {
+ coEvery {
+ appInstallationFacade.processInstall("123", false, any())
+ } returns Result.failure(IllegalStateException("boom"))
+ val worker = createWorker(
+ Data.Builder()
+ .putString(InstallAppWorker.INPUT_DATA_FUSED_DOWNLOAD, "123")
+ .putBoolean(InstallAppWorker.IS_UPDATE_WORK, false)
+ .build()
+ )
+
+ val result = worker.doWork()
+
+ assertThat(result).isEqualTo(androidx.work.ListenableWorker.Result.failure())
+ coVerify { appInstallationFacade.processInstall("123", false, any()) }
+ }
+
+ private fun createWorker(inputData: Data): InstallAppWorker {
+ val params = mockk(relaxed = true)
+ every { params.inputData } returns inputData
+
+ return InstallAppWorker(
+ ApplicationProvider.getApplicationContext(),
+ params,
+ appInstallationFacade
+ )
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b3c59cd8ccf2fbd9dfe416dffbf7c6943ca5d438
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationCompletionHandlerTest.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.content.Context
+import com.aurora.gplayapi.data.models.AuthData
+import foundation.e.apps.R
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler
+import foundation.e.apps.data.installation.port.UpdatesNotificationSender
+import foundation.e.apps.data.installation.port.UpdatesTracker
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.domain.model.install.Status
+import foundation.e.apps.util.MainCoroutineRule
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import java.util.Locale
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class InstallationCompletionHandlerTest {
+
+ @get:Rule
+ var mainCoroutineRule = MainCoroutineRule()
+
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var playStoreAuthStore: PlayStoreAuthStore
+ private lateinit var updatesTracker: UpdatesTracker
+ private lateinit var updatesNotificationSender: UpdatesNotificationSender
+ private lateinit var context: Context
+ private lateinit var handler: InstallationCompletionHandler
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ appInstallRepository = AppInstallRepository(FakeAppInstallDAO())
+ appManagerWrapper = mockk(relaxed = true)
+ playStoreAuthStore = mockk(relaxed = true)
+ updatesTracker = mockk(relaxed = true)
+ updatesNotificationSender = mockk(relaxed = true)
+ coEvery { playStoreAuthStore.awaitAuthData() } returns null
+ handler = InstallationCompletionHandler(
+ context,
+ appInstallRepository,
+ appManagerWrapper,
+ playStoreAuthStore,
+ updatesTracker,
+ updatesNotificationSender
+ )
+ }
+
+ @Test
+ fun onInstallFinished_doesNothingWhenNotUpdateWork() = runTest {
+ handler.onInstallFinished(AppInstall(id = "123", packageName = "com.example.app"), false)
+
+ verify(exactly = 0) { appManagerWrapper.getFusedDownloadPackageStatus(any()) }
+ verify(exactly = 0) { updatesTracker.addSuccessfullyUpdatedApp(any()) }
+ verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) }
+ }
+
+ @Test
+ fun onInstallFinished_tracksInstalledUpdates() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns false
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesTracker.addSuccessfullyUpdatedApp(appInstall) }
+ verify(exactly = 0) { updatesNotificationSender.showNotification(any(), any()) }
+ }
+
+ @Test
+ fun onInstallFinished_sendsNotificationWhenUpdateBatchCompletes() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 2
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesNotificationSender.showNotification("Update", "Updated message") }
+ verify { updatesTracker.clearSuccessfullyUpdatedApps() }
+ }
+
+ @Test
+ fun onInstallFinished_ignoresIssueAndPurchaseNeededStatusesForCompletion() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ appInstallRepository.addDownload(
+ AppInstall(
+ id = "issue",
+ status = Status.INSTALLATION_ISSUE,
+ packageName = "com.example.issue"
+ )
+ )
+ appInstallRepository.addDownload(
+ AppInstall(
+ id = "purchase",
+ status = Status.PURCHASE_NEEDED,
+ packageName = "com.example.purchase"
+ )
+ )
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 1
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesNotificationSender.showNotification("Update", "Updated message") }
+ }
+
+ @Test
+ fun onInstallFinished_clearsTrackedUpdatesAfterNotification() = runTest {
+ val appInstall = AppInstall(id = "123", packageName = "com.example.app")
+ every { appManagerWrapper.getFusedDownloadPackageStatus(appInstall) } returns Status.INSTALLED
+ every { updatesTracker.hasSuccessfulUpdatedApps() } returns true
+ every { updatesTracker.successfulUpdatedAppsCount() } returns 1
+ stubUpdateNotificationContext()
+
+ handler.onInstallFinished(appInstall, true)
+
+ verify { updatesTracker.clearSuccessfullyUpdatedApps() }
+ }
+
+ private suspend fun stubUpdateNotificationContext() {
+ val authData = AuthData(email = "user@example.com", isAnonymous = false).apply {
+ locale = Locale.US
+ }
+ coEvery { playStoreAuthStore.awaitAuthData() } returns authData
+ every { context.getString(R.string.update) } returns "Update"
+ every {
+ context.getString(
+ R.string.message_last_update_triggered,
+ any(),
+ any()
+ )
+ } returns "Updated message"
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..45943213971c291bdd045f1ac97f6812289b8f06
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationEnqueuerTest.kt
@@ -0,0 +1,497 @@
+/*
+ * 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.work.Operation
+import com.aurora.gplayapi.data.models.AuthData
+import com.aurora.gplayapi.exceptions.InternalException
+import com.google.common.util.concurrent.Futures
+import foundation.e.apps.R
+import foundation.e.apps.data.application.ApplicationRepository
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.event.AppEvent
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.install.notification.StorageNotificationManager
+import foundation.e.apps.data.install.core.helper.AgeLimiter
+import foundation.e.apps.data.install.core.helper.DevicePreconditions
+import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher
+import foundation.e.apps.data.install.core.helper.PreEnqueueChecker
+import foundation.e.apps.data.install.core.InstallationEnqueuer
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
+import foundation.e.apps.data.install.workmanager.InstallWorkManager
+import foundation.e.apps.data.installation.port.NetworkStatusChecker
+import foundation.e.apps.data.installation.port.StorageSpaceChecker
+import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
+import foundation.e.apps.data.preference.PlayStoreAuthStore
+import foundation.e.apps.domain.model.User
+import foundation.e.apps.domain.model.install.Status
+import foundation.e.apps.domain.preferences.SessionRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.justRun
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.CancellationException
+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
+import kotlin.test.assertFailsWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class InstallationEnqueuerTest {
+ private lateinit var context: Context
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var applicationRepository: ApplicationRepository
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var sessionRepository: SessionRepository
+ private lateinit var playStoreAuthStore: PlayStoreAuthStore
+ private lateinit var storageNotificationManager: StorageNotificationManager
+ private lateinit var ageLimiter: AgeLimiter
+ private lateinit var appEventDispatcher: FakeAppEventDispatcher
+ private lateinit var storageSpaceChecker: StorageSpaceChecker
+ private lateinit var networkStatusChecker: NetworkStatusChecker
+ private lateinit var appManager: AppManager
+ private lateinit var devicePreconditions: DevicePreconditions
+ private lateinit var downloadUrlRefresher: DownloadUrlRefresher
+ private lateinit var preEnqueueChecker: PreEnqueueChecker
+ private lateinit var enqueuer: InstallationEnqueuer
+
+ @Before
+ fun setup() {
+ context = mockk(relaxed = true)
+ appManagerWrapper = mockk(relaxed = true)
+ applicationRepository = mockk(relaxed = true)
+ appInstallRepository = mockk(relaxed = true)
+ sessionRepository = mockk(relaxed = true)
+ playStoreAuthStore = mockk(relaxed = true)
+ storageNotificationManager = mockk(relaxed = true)
+ ageLimiter = mockk(relaxed = true)
+ appEventDispatcher = FakeAppEventDispatcher()
+ storageSpaceChecker = mockk(relaxed = true)
+ networkStatusChecker = mockk(relaxed = true)
+ appManager = mockk(relaxed = true)
+ coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE
+ coEvery { playStoreAuthStore.awaitAuthData() } returns null
+ downloadUrlRefresher = DownloadUrlRefresher(
+ applicationRepository,
+ appInstallRepository,
+ appManagerWrapper,
+ appEventDispatcher,
+ appManager
+ )
+ devicePreconditions = DevicePreconditions(
+ appManagerWrapper,
+ appEventDispatcher,
+ storageNotificationManager,
+ storageSpaceChecker,
+ networkStatusChecker
+ )
+ preEnqueueChecker = PreEnqueueChecker(
+ downloadUrlRefresher,
+ appManagerWrapper,
+ ageLimiter,
+ devicePreconditions
+ )
+ enqueuer = InstallationEnqueuer(
+ context,
+ preEnqueueChecker,
+ appManagerWrapper,
+ sessionRepository,
+ playStoreAuthStore,
+ appEventDispatcher
+ )
+ }
+
+ @Test
+ fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenNetworkUnavailable() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns false
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenStorageMissing() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 100L
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ verify { storageNotificationManager.showNotEnoughSpaceNotification(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenAddDownloadFails() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns false
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { ageLimiter.allow(any()) }
+ }
+
+ @Test
+ fun enqueue_warnsAnonymousPaidUsersAndAborts() = runTest {
+ val appInstall = createNativeInstall(isFree = false)
+
+ mockkObject(InstallWorkManager)
+ try {
+ coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS
+ coEvery {
+ playStoreAuthStore.awaitAuthData()
+ } returns AuthData(email = "anon@example.com", isAnonymous = true)
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+ justRun { InstallWorkManager.enqueueWork(any(), any(), any()) }
+
+ val result = enqueuer.enqueue(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify(exactly = 0) { appManagerWrapper.updateAwaiting(appInstall) }
+ verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) }
+ } finally {
+ unmockkObject(InstallWorkManager)
+ }
+ }
+
+ @Test
+ fun enqueue_returnsFalseWhenCanEnqueueFails() = runTest {
+ val appInstall = createPwaInstall()
+
+ mockkObject(InstallWorkManager)
+ try {
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns false
+
+ val result = enqueuer.enqueue(appInstall, isAnUpdate = true)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { appManagerWrapper.updateAwaiting(appInstall) }
+ verify(exactly = 0) { InstallWorkManager.enqueueWork(any(), any(), any()) }
+ } finally {
+ unmockkObject(InstallWorkManager)
+ }
+ }
+
+ @Test
+ fun enqueue_enqueuesUpdateWorkWhenUpdateAndChecksPass() = runTest {
+ val appInstall = createPwaInstall()
+
+ mockkObject(InstallWorkManager)
+ try {
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+ every { InstallWorkManager.getUniqueWorkName(appInstall.packageName) } answers { callOriginal() }
+ every { InstallWorkManager.enqueueWork(context, appInstall, true) } returns successfulOperation()
+
+ val result = enqueuer.enqueue(appInstall, isAnUpdate = true)
+
+ assertTrue(result)
+ coVerify { appManagerWrapper.updateAwaiting(appInstall) }
+ verify(exactly = 1) { InstallWorkManager.enqueueWork(context, appInstall, true) }
+ } finally {
+ unmockkObject(InstallWorkManager)
+ }
+ }
+
+ @Test
+ fun enqueue_handlesGenericExceptionAndMarksInstallationIssue() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+ coEvery { appManagerWrapper.updateAwaiting(appInstall) } throws IllegalStateException("boom")
+
+ val result = enqueuer.enqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ }
+
+ @Test
+ fun enqueue_rethrowsCancellationException() = runTest {
+ val appInstall = createPwaInstall()
+
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+ coEvery { appManagerWrapper.updateAwaiting(appInstall) } throws CancellationException("cancelled")
+
+ assertFailsWith {
+ enqueuer.enqueue(appInstall)
+ }
+ }
+
+ @Test
+ fun enqueue_doesNotWarnAnonymousPaidUsersForSystemApps() = runTest {
+ val appInstall = createPwaInstall(isFree = false)
+
+ coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS
+ coEvery {
+ playStoreAuthStore.awaitAuthData()
+ } returns AuthData(email = "anon@example.com", isAnonymous = true)
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.enqueue(appInstall, isSystemApp = true)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.none {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify(exactly = 0) { playStoreAuthStore.awaitAuthData() }
+ }
+
+ @Test
+ fun enqueue_allowsPaidAppForGoogleUser() = runTest {
+ val appInstall = createNativeInstall(isFree = false)
+
+ coEvery { sessionRepository.awaitUser() } returns User.GOOGLE
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.enqueue(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.none {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify { appManagerWrapper.updateAwaiting(appInstall) }
+ coVerify(exactly = 0) { playStoreAuthStore.awaitAuthData() }
+ }
+
+ @Test
+ fun enqueue_allowsFreeAppForAnonymousUser() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery { sessionRepository.awaitUser() } returns User.ANONYMOUS
+ coEvery {
+ playStoreAuthStore.awaitAuthData()
+ } returns AuthData(email = "anon@example.com", isAnonymous = true)
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.enqueue(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.none {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify { appManagerWrapper.updateAwaiting(appInstall) }
+ coVerify { playStoreAuthStore.awaitAuthData() }
+ }
+
+ @Test
+ fun enqueue_doesNotLookupAuthForNoGoogleUser() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery { sessionRepository.awaitUser() } returns User.NO_GOOGLE
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ every { networkStatusChecker.isNetworkAvailable() } returns true
+ every { storageSpaceChecker.spaceMissing(appInstall) } returns 0L
+
+ val result = enqueuer.enqueue(appInstall)
+
+ assertTrue(result)
+ assertTrue(appEventDispatcher.events.none {
+ it is AppEvent.ErrorMessageEvent && it.data == R.string.paid_app_anonymous_message
+ })
+ coVerify { appManagerWrapper.updateAwaiting(appInstall) }
+ coVerify(exactly = 0) { playStoreAuthStore.awaitAuthData() }
+ }
+
+ @Test
+ fun canEnqueue_handlesFreeAppNotPurchasedAsRestricted() = runTest {
+ val appInstall = createNativeInstall(isFree = true)
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws InternalException.AppNotPurchased()
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppRestrictedOrUnavailable })
+ coVerify { appManager.addDownload(appInstall) }
+ coVerify { appManager.updateUnavailable(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_handlesPaidAppNotPurchasedAsPurchaseNeeded() = runTest {
+ val appInstall = createNativeInstall(isFree = false)
+
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws InternalException.AppNotPurchased()
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appManagerWrapper.addFusedDownloadPurchaseNeeded(appInstall) }
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.AppPurchaseEvent })
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsHttpError() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws GplayHttpRequestException(403, "forbidden")
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.none { it is AppEvent.UpdateEvent })
+ coVerify { appInstallRepository.addDownload(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_dispatchesUpdateEventWhenDownloadUrlRefreshFailsForUpdate() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws GplayHttpRequestException(403, "forbidden")
+
+ val result = enqueuer.canEnqueue(appInstall, true)
+
+ assertFalse(result)
+ assertTrue(appEventDispatcher.events.any { it is AppEvent.UpdateEvent })
+ coVerify { appInstallRepository.addDownload(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) }
+ }
+
+ @Test
+ fun canEnqueue_returnsFalseWhenDownloadUrlRefreshThrowsIllegalState() = runTest {
+ val appInstall = createNativeInstall()
+
+ coEvery { appInstallRepository.getDownloadById(appInstall.id) } returns null
+ coEvery {
+ applicationRepository.updateFusedDownloadWithDownloadingInfo(
+ Source.PLAY_STORE,
+ appInstall
+ )
+ } throws IllegalStateException("boom")
+
+ val result = enqueuer.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify { appInstallRepository.addDownload(appInstall) }
+ coVerify { appManagerWrapper.installationIssue(appInstall) }
+ coVerify(exactly = 0) { appManagerWrapper.addDownload(appInstall) }
+ }
+
+ private fun successfulOperation(): Operation {
+ val operation = mockk()
+ every { operation.result } returns Futures.immediateFuture(Operation.SUCCESS)
+ return operation
+ }
+
+ private fun createPwaInstall(isFree: Boolean = true) = AppInstall(
+ type = InstallationType.PWA,
+ id = "123",
+ status = Status.AWAITING,
+ downloadURLList = mutableListOf("apk"),
+ packageName = "com.example.app",
+ isFree = isFree
+ )
+
+ private fun createNativeInstall(isFree: Boolean = true) = AppInstall(
+ type = InstallationType.NATIVE,
+ source = InstallationSource.PLAY_STORE,
+ id = "123",
+ status = Status.AWAITING,
+ packageName = "com.example.app",
+ isFree = isFree
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c737deb47ee01a001c153605e82183211e7c3781
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationProcessorTest.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import foundation.e.apps.data.fdroid.FDroidRepository
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.data.install.AppManager
+import foundation.e.apps.data.install.download.DownloadManagerUtils
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.core.InstallationProcessor
+import foundation.e.apps.data.install.core.helper.InstallationCompletionHandler
+import foundation.e.apps.domain.model.install.Status
+import foundation.e.apps.util.MainCoroutineRule
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class InstallationProcessorTest {
+ @Rule
+ @JvmField
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ var mainCoroutineRule = MainCoroutineRule()
+
+ private lateinit var fakeFusedDownloadDAO: FakeAppInstallDAO
+ private lateinit var appInstallRepository: AppInstallRepository
+ private lateinit var fakeFusedManagerRepository: FakeAppManagerWrapper
+ private lateinit var downloadManagerUtils: DownloadManagerUtils
+ private lateinit var installationCompletionHandler: InstallationCompletionHandler
+ private lateinit var workRunner: InstallationProcessor
+ private lateinit var context: Context
+
+ @Mock
+ private lateinit var fakeFusedManager: AppManager
+
+ @Mock
+ private lateinit var fakeFDroidRepository: FDroidRepository
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ context = mockk(relaxed = true)
+ fakeFusedDownloadDAO = FakeAppInstallDAO()
+ appInstallRepository = AppInstallRepository(fakeFusedDownloadDAO)
+ fakeFusedManagerRepository =
+ FakeAppManagerWrapper(fakeFusedDownloadDAO, context, fakeFusedManager, fakeFDroidRepository)
+ downloadManagerUtils = mockk(relaxed = true)
+ installationCompletionHandler = mockk(relaxed = true)
+ workRunner = InstallationProcessor(
+ appInstallRepository,
+ fakeFusedManagerRepository,
+ downloadManagerUtils,
+ installationCompletionHandler
+ )
+ }
+
+ @Test
+ fun processInstall_completesNormalFlow() = runTest {
+ val fusedDownload = initTest()
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertTrue(finalFusedDownload == null)
+ }
+
+ @Test
+ fun processInstall_keepsBlockedDownloadUntouched() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.status = Status.BLOCKED
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.BLOCKED, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_marksDownloadedFilesAsInstalling() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertTrue(finalFusedDownload == null)
+ }
+
+ @Test
+ fun processInstall_reportsInvalidPackageAsInstallationIssue() = runTest {
+ val fusedDownload = initTest(packageName = "")
+ fusedDownload.downloadIdMap = mutableMapOf(Pair(231, true))
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_reportsMissingDownloadUrlsAsInstallationIssue() = runTest {
+ val fusedDownload = initTest(downloadUrlList = mutableListOf())
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_returnsFailureWhenInternalExceptionOccurs() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.forceCrash = true
+
+ val result = workRunner.processInstall(fusedDownload.id, false) {
+ // _ignored_
+ }
+ val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id)
+
+ assertTrue(result.isFailure)
+ assertTrue(finalFusedDownload == null || fusedDownload.status == Status.INSTALLATION_ISSUE)
+ }
+
+ @Test
+ fun processInstall_returnsFailureWhenStatusIsInvalid() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.status = Status.BLOCKED
+
+ val result = workRunner.processInstall(fusedDownload.id, false) {
+ // _ignored_
+ }
+ val finalFusedDownload = fakeFusedDownloadDAO.getDownloadById(fusedDownload.id)
+
+ assertTrue(result.isFailure)
+ assertEquals(Status.BLOCKED, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_returnsFailureWhenDownloadMissing() = runTest {
+ val result = workRunner.processInstall("missing", false) {
+ // _ignored_
+ }
+
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is IllegalStateException)
+ }
+
+ @Test
+ fun processInstall_reportsDownloadFailure() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.willDownloadFail = true
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_reportsInstallFailure() = runTest {
+ val fusedDownload = initTest()
+ fakeFusedManagerRepository.willInstallFail = true
+
+ val finalFusedDownload = runProcessInstall(fusedDownload)
+
+ assertEquals(Status.INSTALLATION_ISSUE, finalFusedDownload?.status)
+ }
+
+ @Test
+ fun processInstall_updatesDownloadManagerStateForDownloadingItems() = runTest {
+ val fusedDownload = initTest()
+ fusedDownload.status = Status.DOWNLOADING
+ fusedDownload.downloadURLList = mutableListOf()
+ fusedDownload.downloadIdMap = mutableMapOf(231L to false, 232L to false)
+
+ workRunner.processInstall(fusedDownload.id, false) {
+ // _ignored_
+ }
+
+ verify { downloadManagerUtils.updateDownloadStatus(231L) }
+ verify { downloadManagerUtils.updateDownloadStatus(232L) }
+ }
+
+ private suspend fun initTest(
+ packageName: String? = null,
+ downloadUrlList: MutableList? = null
+ ): AppInstall {
+ val fusedDownload = createFusedDownload(packageName, downloadUrlList)
+ fakeFusedDownloadDAO.addDownload(fusedDownload)
+ return fusedDownload
+ }
+
+ private suspend fun runProcessInstall(appInstall: AppInstall): AppInstall? {
+ workRunner.processInstall(appInstall.id, false) {
+ // _ignored_
+ }
+ return fakeFusedDownloadDAO.getDownloadById(appInstall.id)
+ }
+
+ private fun createFusedDownload(
+ packageName: String? = null,
+ downloadUrlList: MutableList? = null
+ ) = AppInstall(
+ id = "121",
+ status = Status.AWAITING,
+ downloadURLList = downloadUrlList ?: mutableListOf("apk1", "apk2"),
+ packageName = packageName ?: "com.unit.test"
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a660b6ac652026c69857fd71200956a798043ba6
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/InstallationRequestTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import com.aurora.gplayapi.data.models.ContentRating
+import foundation.e.apps.data.application.data.Application
+import foundation.e.apps.data.enums.Source
+import foundation.e.apps.data.enums.Type
+import foundation.e.apps.data.install.core.InstallationRequest
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
+import foundation.e.apps.domain.model.install.Status
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class InstallationRequestTest {
+ private lateinit var installationRequest: InstallationRequest
+
+ @Before
+ fun setup() {
+ installationRequest = InstallationRequest()
+ }
+
+ @Test
+ fun create_copiesExpectedFields() {
+ val application = Application(
+ _id = "123",
+ source = Source.PLAY_STORE,
+ status = Status.AWAITING,
+ name = "Example",
+ package_name = "com.example.app",
+ type = Type.NATIVE,
+ icon_image_path = "icon.png",
+ latest_version_code = 42,
+ offer_type = 1,
+ isFree = false,
+ originalSize = 2048L
+ )
+
+ val appInstall = installationRequest.create(application)
+
+ assertEquals("123", appInstall.id)
+ assertEquals(InstallationSource.PLAY_STORE, appInstall.source)
+ assertEquals(Status.AWAITING, appInstall.status)
+ assertEquals("Example", appInstall.name)
+ assertEquals("com.example.app", appInstall.packageName)
+ assertEquals(InstallationType.NATIVE, appInstall.type)
+ assertEquals("icon.png", appInstall.iconImageUrl)
+ assertEquals(42, appInstall.versionCode)
+ assertEquals(1, appInstall.offerType)
+ assertEquals(false, appInstall.isFree)
+ assertEquals(2048L, appInstall.appSize)
+ }
+
+ @Test
+ fun create_setsContentRating() {
+ val contentRating = ContentRating()
+ val application = Application(contentRating = contentRating)
+
+ val appInstall = installationRequest.create(application)
+
+ assertEquals(contentRating, appInstall.contentRating)
+ }
+
+ @Test
+ fun create_initializesDirectUrlForPwa() {
+ val application = Application(type = Type.PWA, url = "https://example.com")
+
+ val appInstall = installationRequest.create(application)
+
+ assertEquals(mutableListOf("https://example.com"), appInstall.downloadURLList)
+ }
+
+ @Test
+ fun create_initializesDirectUrlForSystemApp() {
+ val application = Application(source = Source.SYSTEM_APP, url = "file://app.apk")
+
+ val appInstall = installationRequest.create(application)
+
+ assertEquals(mutableListOf("file://app.apk"), appInstall.downloadURLList)
+ }
+
+ @Test
+ fun create_doesNotForceDirectUrlForNativeNonSystemApp() {
+ val application =
+ Application(source = Source.PLAY_STORE, type = Type.NATIVE, url = "ignored")
+
+ val appInstall = installationRequest.create(application)
+
+ assertTrue(appInstall.downloadURLList.isEmpty())
+ }
+}
diff --git a/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt b/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9b849dd351286acef76b1a1bf199e01a86ab2ebd
--- /dev/null
+++ b/app/src/test/java/foundation/e/apps/installProcessor/PreEnqueueCheckerTest.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2026 e Foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package foundation.e.apps.installProcessor
+
+import foundation.e.apps.data.install.AppManagerWrapper
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.install.core.helper.AgeLimiter
+import foundation.e.apps.data.install.core.helper.DevicePreconditions
+import foundation.e.apps.data.install.core.helper.DownloadUrlRefresher
+import foundation.e.apps.data.install.core.helper.PreEnqueueChecker
+import foundation.e.apps.data.installation.model.InstallationSource
+import foundation.e.apps.data.installation.model.InstallationType
+import foundation.e.apps.domain.model.install.Status
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PreEnqueueCheckerTest {
+ private lateinit var downloadUrlRefresher: DownloadUrlRefresher
+ private lateinit var appManagerWrapper: AppManagerWrapper
+ private lateinit var ageLimiter: AgeLimiter
+ private lateinit var devicePreconditions: DevicePreconditions
+ private lateinit var checker: PreEnqueueChecker
+
+ @Before
+ fun setup() {
+ downloadUrlRefresher = mockk(relaxed = true)
+ appManagerWrapper = mockk(relaxed = true)
+ ageLimiter = mockk(relaxed = true)
+ devicePreconditions = mockk(relaxed = true)
+ checker = PreEnqueueChecker(
+ downloadUrlRefresher,
+ appManagerWrapper,
+ ageLimiter,
+ devicePreconditions
+ )
+ }
+
+ @Test
+ fun canEnqueue_skipsDownloadUrlRefreshForPwaInstalls() = runTest {
+ val appInstall = createPwaInstall()
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ coEvery { devicePreconditions.canProceed(appInstall) } returns true
+
+ val result = checker.canEnqueue(appInstall)
+
+ assertTrue(result)
+ coVerify(exactly = 0) {
+ downloadUrlRefresher.updateDownloadUrls(any(), any())
+ }
+ }
+
+ @Test
+ fun canEnqueue_stopsWhenDownloadRefreshFails() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns false
+
+ val result = checker.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { appManagerWrapper.addDownload(any()) }
+ coVerify(exactly = 0) { ageLimiter.allow(any()) }
+ coVerify(exactly = 0) { devicePreconditions.canProceed(any()) }
+ }
+
+ @Test
+ fun canEnqueue_stopsWhenAddingDownloadFails() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns false
+
+ val result = checker.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { ageLimiter.allow(any()) }
+ coVerify(exactly = 0) { devicePreconditions.canProceed(any()) }
+ }
+
+ @Test
+ fun canEnqueue_stopsWhenAgeLimitRejectsInstall() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns false
+
+ val result = checker.canEnqueue(appInstall)
+
+ assertFalse(result)
+ coVerify(exactly = 0) { devicePreconditions.canProceed(any()) }
+ }
+
+ @Test
+ fun canEnqueue_returnsTrueWhenAllChecksPass() = runTest {
+ val appInstall = createNativeInstall()
+ coEvery { downloadUrlRefresher.updateDownloadUrls(appInstall, false) } returns true
+ coEvery { appManagerWrapper.addDownload(appInstall) } returns true
+ coEvery { ageLimiter.allow(appInstall) } returns true
+ coEvery { devicePreconditions.canProceed(appInstall) } returns true
+
+ val result = checker.canEnqueue(appInstall)
+
+ assertTrue(result)
+ }
+
+ private fun createPwaInstall() = AppInstall(
+ type = InstallationType.PWA,
+ id = "123",
+ status = Status.AWAITING,
+ downloadURLList = mutableListOf("apk"),
+ packageName = "com.example.app"
+ )
+
+ private fun createNativeInstall() = AppInstall(
+ type = InstallationType.NATIVE,
+ source = InstallationSource.PLAY_STORE,
+ id = "123",
+ status = Status.AWAITING,
+ packageName = "com.example.app"
+ )
+}
diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt
index 318653c4e7cc7a05322a9a293b93dfe8de296708..1093782848b07184719b0969486b4a0426aae771 100644
--- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt
+++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusReconcilerTest.kt
@@ -21,7 +21,7 @@ import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.domain.model.install.Status
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.install.download.data.DownloadProgress
import io.mockk.coEvery
import io.mockk.coVerify
diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt
index 93b3debb4be3356f3baebb801e34dc2bcff5211c..0c9faefb875050aced1a2ff54c193fc993564a0f 100644
--- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt
+++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallStatusStreamTest.kt
@@ -21,7 +21,7 @@ import android.content.pm.ApplicationInfo
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.MutableLiveData
import foundation.e.apps.data.install.AppManagerWrapper
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.install.pkg.PwaManager
import foundation.e.apps.util.MainCoroutineRule
diff --git a/data/build.gradle b/data/build.gradle
index ddb16e707bf2994f37b5791bb0d89143b625bfdd..95588f25201ee9ffe07ba4acabd8c0f2d4076ead 100644
--- a/data/build.gradle
+++ b/data/build.gradle
@@ -1,6 +1,8 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
+ id 'com.google.devtools.ksp'
+ id 'com.google.dagger.hilt.android'
id 'jacoco'
}
@@ -51,7 +53,16 @@ dependencies {
implementation libs.kotlinx.coroutines.core
implementation libs.kotlinx.serialization.json
implementation libs.gplayapi
+ implementation libs.gson
implementation libs.hilt.android
+ implementation libs.lifecycle.livedata.ktx
+ implementation libs.room.ktx
+ implementation libs.room.runtime
+ implementation libs.timber
+
+ ksp libs.room.compiler
+ ksp libs.hilt.compile
+ ksp libs.hilt.compiler
testImplementation libs.junit
testImplementation libs.truth
diff --git a/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt b/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..33b70515b6848824470254380a407dd94eaa95f8
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/core/InstallationProcessor.kt
@@ -0,0 +1,174 @@
+package foundation.e.apps.data.installation.core
+
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.data.installation.model.InstallationResult
+import foundation.e.apps.data.installation.port.InstallationAppManager
+import foundation.e.apps.data.installation.port.InstallationCompletionNotifier
+import foundation.e.apps.data.installation.port.InstallationDownloadStatusUpdater
+import foundation.e.apps.data.installation.repository.AppInstallRepository
+import foundation.e.apps.domain.model.install.Status
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.flow.transformWhile
+import timber.log.Timber
+import javax.inject.Inject
+
+class InstallationProcessor @Inject constructor(
+ private val appInstallRepository: AppInstallRepository,
+ private val installationAppManager: InstallationAppManager,
+ private val installationDownloadStatusUpdater: InstallationDownloadStatusUpdater,
+ private val installationCompletionNotifier: InstallationCompletionNotifier,
+) {
+ @Suppress("ReturnCount")
+ @OptIn(DelicateCoroutinesApi::class)
+ suspend fun processInstall(
+ fusedDownloadId: String,
+ isItUpdateWork: Boolean,
+ runInForeground: suspend (String) -> Unit
+ ): Result {
+ val appInstall =
+ appInstallRepository.getDownloadById(fusedDownloadId) ?: return Result.failure(
+ IllegalStateException("App can't be null here.")
+ )
+
+ Timber.i(">>> doWork() started for ${appInstall.name}/${appInstall.packageName}")
+
+ checkDownloadingState(appInstall)
+
+ if (!appInstall.isAppInstalling()) {
+ val message = "${appInstall.status} is in invalid state"
+ Timber.w(message)
+
+ return Result.failure(IllegalStateException(message))
+ }
+
+ if (!installationAppManager.validateFusedDownload(appInstall)) {
+ installationAppManager.installationIssue(appInstall)
+ val message = "Installation issue for ${appInstall.name}/${appInstall.packageName}"
+ Timber.w(message)
+
+ return Result.failure(IllegalStateException(message))
+ }
+
+ return runCatching {
+ val isUpdateWork =
+ isItUpdateWork && installationAppManager.isFusedDownloadInstalled(appInstall)
+
+ if (areFilesDownloadedButNotInstalled(appInstall)) {
+ Timber.i("===> Downloaded But not installed ${appInstall.name}")
+ installationAppManager.updateDownloadStatus(appInstall, Status.INSTALLING)
+ }
+
+ runInForeground.invoke(appInstall.name)
+ startAppInstallationProcess(appInstall, isUpdateWork)
+ Timber.i("doWork: RESULT SUCCESS: ${appInstall.name}")
+
+ InstallationResult.OK
+ }.onFailure { exception ->
+ if (exception is CancellationException) {
+ throw exception
+ }
+
+ Timber.e(
+ exception,
+ "Install worker failed for ${appInstall.packageName} exception: ${exception.message}"
+ )
+
+ installationAppManager.installationIssue(appInstall)
+ installationAppManager.cancelDownload(appInstall)
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun checkDownloadingState(appInstall: AppInstall) {
+ if (appInstall.status == Status.DOWNLOADING) {
+ appInstall.downloadIdMap.keys.forEach { downloadId ->
+ installationDownloadStatusUpdater.updateDownloadStatus(downloadId)
+ }
+ }
+ }
+
+ private fun areFilesDownloadedButNotInstalled(appInstall: AppInstall): Boolean = appInstall.areFilesDownloaded() &&
+ (!installationAppManager.isFusedDownloadInstalled(appInstall) || appInstall.status == Status.INSTALLING)
+
+ private suspend fun startAppInstallationProcess(appInstall: AppInstall, isUpdateWork: Boolean) {
+ if (appInstall.isAwaiting()) {
+ installationAppManager.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)
+ installationAppManager.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 -> installationAppManager.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) {
+ installationCompletionNotifier.onInstallFinished(appInstall, isUpdateWork)
+ }
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1fbf62d44f47c12d7f9aacd7c89d3b3ab9762c2f
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/di/AppInstallPersistenceModule.kt
@@ -0,0 +1,27 @@
+package foundation.e.apps.data.installation.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import foundation.e.apps.data.installation.local.AppInstallDAO
+import foundation.e.apps.data.installation.local.AppInstallDatabase
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppInstallPersistenceModule {
+ @Singleton
+ @Provides
+ fun provideDatabaseInstance(@ApplicationContext context: Context): AppInstallDatabase {
+ return AppInstallDatabase.getInstance(context)
+ }
+
+ @Singleton
+ @Provides
+ fun provideFusedDaoInstance(appInstallDatabase: AppInstallDatabase): AppInstallDAO {
+ return appInstallDatabase.fusedDownloadDao()
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallConverter.kt
similarity index 96%
rename from app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt
rename to data/src/main/java/foundation/e/apps/data/installation/local/AppInstallConverter.kt
index 3c860e005a3327a26a957e2d9c2d983c11cb7619..521d98326db7e3b6ba1dcb6483911f82b80f4942 100644
--- a/app/src/main/java/foundation/e/apps/data/database/install/AppInstallConverter.kt
+++ b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallConverter.kt
@@ -1,4 +1,4 @@
-package foundation.e.apps.data.database.install
+package foundation.e.apps.data.installation.local
import androidx.room.TypeConverter
import com.aurora.gplayapi.data.models.ContentRating
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDAO.kt
similarity index 91%
rename from app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt
rename to data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDAO.kt
index 397f993cfcc5a8249f60d242530daa16bd7031b1..0c81f263c39cede1f2bdcbadbba308f9bf3fdf0e 100644
--- a/app/src/main/java/foundation/e/apps/data/install/AppInstallDAO.kt
+++ b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDAO.kt
@@ -1,4 +1,4 @@
-package foundation.e.apps.data.install
+package foundation.e.apps.data.installation.local
import androidx.lifecycle.LiveData
import androidx.room.Dao
@@ -7,7 +7,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.model.AppInstall
import kotlinx.coroutines.flow.Flow
@Dao
diff --git a/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt
new file mode 100644
index 0000000000000000000000000000000000000000..864b1ca5a298c161f1b757e97405552c385f8738
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/local/AppInstallDatabase.kt
@@ -0,0 +1,34 @@
+package foundation.e.apps.data.installation.local
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import foundation.e.apps.data.installation.model.AppInstall
+
+@Database(entities = [AppInstall::class], version = 6, exportSchema = false)
+@TypeConverters(AppInstallConverter::class)
+abstract class AppInstallDatabase : RoomDatabase() {
+ abstract fun fusedDownloadDao(): AppInstallDAO
+
+ companion object {
+ private lateinit var instance: AppInstallDatabase
+ private val lock = Any()
+ private const val DATABASE_NAME = "fused_database"
+
+ fun getInstance(context: Context): AppInstallDatabase {
+ if (!Companion::instance.isInitialized) {
+ synchronized(lock) {
+ if (!Companion::instance.isInitialized) {
+ instance =
+ Room.databaseBuilder(context, AppInstallDatabase::class.java, DATABASE_NAME)
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+ }
+ }
+ return instance
+ }
+ }
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt
similarity index 72%
rename from app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt
rename to data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt
index ab9534435081abd793b25851e72a4445f019b984..cfe32dfbb4875dadfc3086b847e288081ae5cd18 100644
--- a/app/src/main/java/foundation/e/apps/data/install/models/AppInstall.kt
+++ b/data/src/main/java/foundation/e/apps/data/installation/model/AppInstall.kt
@@ -1,26 +1,23 @@
-package foundation.e.apps.data.install.models
+package foundation.e.apps.data.installation.model
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.aurora.gplayapi.data.models.ContentRating
import com.aurora.gplayapi.data.models.PlayFile
-import foundation.e.apps.data.cleanapk.CleanApkRetrofit
-import foundation.e.apps.data.enums.Source
-import foundation.e.apps.data.enums.Type
import foundation.e.apps.domain.model.install.Status
@Entity(tableName = "FusedDownload")
data class AppInstall(
@PrimaryKey val id: String = String(),
- val source: Source = Source.PLAY_STORE,
+ val source: InstallationSource = InstallationSource.PLAY_STORE,
var status: Status = Status.UNAVAILABLE,
val name: String = String(),
val packageName: String = String(),
var downloadURLList: MutableList = mutableListOf(),
var downloadIdMap: MutableMap = mutableMapOf(),
val orgStatus: Status = Status.UNAVAILABLE,
- val type: Type = Type.NATIVE,
+ val type: InstallationType = InstallationType.NATIVE,
val iconImageUrl: String = String(),
val versionCode: Long = 1,
val offerType: Int = -1,
@@ -43,11 +40,4 @@ data class AppInstall(
fun isAwaiting() = status == Status.AWAITING
fun areFilesDownloaded() = downloadIdMap.isNotEmpty() && !downloadIdMap.values.contains(false)
-
- fun getAppIconUrl(): String {
- if (this.source == Source.PLAY_STORE || this.source == Source.PWA) {
- return "${CleanApkRetrofit.ASSET_URL}${this.iconImageUrl}"
- }
- return this.iconImageUrl
- }
}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/InstallationResult.kt b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationResult.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1a850266ce47a03d588cd16254ef56266be7ff9a
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationResult.kt
@@ -0,0 +1,10 @@
+package foundation.e.apps.data.installation.model
+
+enum class InstallationResult {
+ OK,
+ TIMEOUT,
+ UNKNOWN,
+ RETRY;
+
+ var message: String = ""
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..98c515130edd274e1cf852011852f392213ba67e
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationSource.kt
@@ -0,0 +1,8 @@
+package foundation.e.apps.data.installation.model
+
+enum class InstallationSource {
+ OPEN_SOURCE,
+ PWA,
+ SYSTEM_APP,
+ PLAY_STORE
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/model/InstallationType.kt b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationType.kt
new file mode 100644
index 0000000000000000000000000000000000000000..495c91cbc9a7d87c1edc2732fc9496e6f2db4b8a
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/model/InstallationType.kt
@@ -0,0 +1,6 @@
+package foundation.e.apps.data.installation.model
+
+enum class InstallationType {
+ NATIVE,
+ PWA
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/InstallationAppManager.kt b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationAppManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2503e5415cc3c7b7ccbe825fadb13dbb81416d94
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationAppManager.kt
@@ -0,0 +1,18 @@
+package foundation.e.apps.data.installation.port
+
+import foundation.e.apps.data.installation.model.AppInstall
+import foundation.e.apps.domain.model.install.Status
+
+interface InstallationAppManager {
+ fun validateFusedDownload(appInstall: AppInstall): Boolean
+
+ suspend fun installationIssue(appInstall: AppInstall)
+
+ fun isFusedDownloadInstalled(appInstall: AppInstall): Boolean
+
+ suspend fun updateDownloadStatus(appInstall: AppInstall, status: Status)
+
+ suspend fun cancelDownload(appInstall: AppInstall)
+
+ suspend fun downloadApp(appInstall: AppInstall, isUpdate: Boolean)
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/InstallationCompletionNotifier.kt b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationCompletionNotifier.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1a16b58e9b0b87389fae50a00be8c085c6e88a01
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationCompletionNotifier.kt
@@ -0,0 +1,7 @@
+package foundation.e.apps.data.installation.port
+
+import foundation.e.apps.data.installation.model.AppInstall
+
+interface InstallationCompletionNotifier {
+ suspend fun onInstallFinished(appInstall: AppInstall?, isUpdateWork: Boolean)
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/InstallationDownloadStatusUpdater.kt b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationDownloadStatusUpdater.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bd3e8865f8143b85807f51e924245e65f84b9c5a
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/InstallationDownloadStatusUpdater.kt
@@ -0,0 +1,5 @@
+package foundation.e.apps.data.installation.port
+
+interface InstallationDownloadStatusUpdater {
+ fun updateDownloadStatus(downloadId: Long)
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/NetworkStatusChecker.kt b/data/src/main/java/foundation/e/apps/data/installation/port/NetworkStatusChecker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2db770769f4569e89d95c763df0cdb4c5c3cfb5c
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/NetworkStatusChecker.kt
@@ -0,0 +1,5 @@
+package foundation.e.apps.data.installation.port
+
+interface NetworkStatusChecker {
+ fun isNetworkAvailable(): Boolean
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/ParentalControlAuthGateway.kt b/data/src/main/java/foundation/e/apps/data/installation/port/ParentalControlAuthGateway.kt
new file mode 100644
index 0000000000000000000000000000000000000000..19b1b7ba3c952b5f163d2c998651f2d70b8e8223
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/ParentalControlAuthGateway.kt
@@ -0,0 +1,5 @@
+package foundation.e.apps.data.installation.port
+
+interface ParentalControlAuthGateway {
+ suspend fun awaitAuthentication(): Boolean
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/StorageSpaceChecker.kt b/data/src/main/java/foundation/e/apps/data/installation/port/StorageSpaceChecker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f0f51d304e0738b5014ea5f3a9ec98225a296db4
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/StorageSpaceChecker.kt
@@ -0,0 +1,7 @@
+package foundation.e.apps.data.installation.port
+
+import foundation.e.apps.data.installation.model.AppInstall
+
+interface StorageSpaceChecker {
+ fun spaceMissing(appInstall: AppInstall): Long
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesNotificationSender.kt b/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesNotificationSender.kt
new file mode 100644
index 0000000000000000000000000000000000000000..704d41ce895cc05d70b4a5491fbc3036c88dc2be
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesNotificationSender.kt
@@ -0,0 +1,5 @@
+package foundation.e.apps.data.installation.port
+
+interface UpdatesNotificationSender {
+ fun showNotification(title: String, message: String)
+}
diff --git a/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesTracker.kt b/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesTracker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..71addec59a26872c949958c66da808e78bf7ff80
--- /dev/null
+++ b/data/src/main/java/foundation/e/apps/data/installation/port/UpdatesTracker.kt
@@ -0,0 +1,13 @@
+package foundation.e.apps.data.installation.port
+
+import foundation.e.apps.data.installation.model.AppInstall
+
+interface UpdatesTracker {
+ fun addSuccessfullyUpdatedApp(appInstall: AppInstall)
+
+ fun clearSuccessfullyUpdatedApps()
+
+ fun hasSuccessfulUpdatedApps(): Boolean
+
+ fun successfulUpdatedAppsCount(): Int
+}
diff --git a/app/src/main/java/foundation/e/apps/data/install/AppInstallRepository.kt b/data/src/main/java/foundation/e/apps/data/installation/repository/AppInstallRepository.kt
similarity index 76%
rename from app/src/main/java/foundation/e/apps/data/install/AppInstallRepository.kt
rename to data/src/main/java/foundation/e/apps/data/installation/repository/AppInstallRepository.kt
index ddebd3709625f97f3457cb88eb63ef0263a70563..56fcf3badc0fa2d4f85f0850f6841805cf9411c4 100644
--- a/app/src/main/java/foundation/e/apps/data/install/AppInstallRepository.kt
+++ b/data/src/main/java/foundation/e/apps/data/installation/repository/AppInstallRepository.kt
@@ -1,9 +1,9 @@
-package foundation.e.apps.data.install
+package foundation.e.apps.data.installation.repository
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
-import foundation.e.apps.OpenForTesting
-import foundation.e.apps.data.install.models.AppInstall
+import foundation.e.apps.data.installation.local.AppInstallDAO
+import foundation.e.apps.data.installation.model.AppInstall
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -11,7 +11,6 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-@OpenForTesting
class AppInstallRepository @Inject constructor(
private val appInstallDAO: AppInstallDAO
) {
@@ -34,6 +33,16 @@ class AppInstallRepository @Inject constructor(
return appInstallDAO.getDownloadLiveList()
}
+ fun getDownloads(): Flow> {
+ return appInstallDAO.getDownloads()
+ }
+
+ suspend fun getItemInInstallation(): List {
+ mutex.withLock {
+ return appInstallDAO.getItemInInstallation()
+ }
+ }
+
suspend fun updateDownload(appInstall: AppInstall) {
mutex.withLock {
appInstallDAO.updateDownload(appInstall)