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)