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 e4c2087a5a07a086c03032594dea394151b65d35..2f6fa76aa61c5526d1600ce378b633c28ada81dc 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 @@ -19,6 +19,7 @@ package foundation.e.apps.data.application import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.Stores import foundation.e.apps.data.application.apps.AppsApi @@ -27,33 +28,112 @@ import foundation.e.apps.data.application.category.CategoryApi import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Home import foundation.e.apps.data.application.downloadInfo.DownloadInfoApi -import foundation.e.apps.data.application.home.HomeApi import foundation.e.apps.data.application.utils.CategoryType 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.enums.Status +import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.install.models.AppInstall +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import javax.inject.Inject import javax.inject.Singleton @Singleton class ApplicationRepository @Inject constructor( - private val homeApi: HomeApi, private val categoryApi: CategoryApi, private val appsApi: AppsApi, private val downloadInfoApi: DownloadInfoApi, private val stores: Stores ) { companion object { - const val APP_TYPE_ANY = "any" - const val APP_TYPE_OPEN = "open" - const val APP_TYPE_PWA = "pwa" - } - - suspend fun getHomeScreenData(): LiveData>> { - return homeApi.fetchHomeScreenData() - } + const val APP_TYPE_ANY = "any" + const val APP_TYPE_OPEN = "open" + const val APP_TYPE_PWA = "pwa" + } + + private enum class AppSourceWeight { + GPLAY, + OPEN_SOURCE, + PWA + } + + fun getHomeScreenData(): LiveData>> { + return liveData { + suspend fun emitResult( + result: ResultSupreme>, + resultsBySource: Map>> + ) { + val merged = mergeResults(resultsBySource.values) + emit( + ResultSupreme.create( + result.getResultStatus(), + merged, + result.message, + result.exception, + ) + ) + } + + coroutineScope { + val resultsBySource = mutableMapOf>>() + val sources = stores.getStores().keys + val deferredResults = sources.associateWith { source -> + async { loadHomeData(source) } + } + + sources.forEach { source -> + val result = deferredResults.getValue(source).await() + resultsBySource[source] = result + emitResult(result, resultsBySource) + } + } + } + } + + private suspend fun loadHomeData(source: Source): ResultSupreme> { + val list = mutableListOf() + val result = handleNetworkResult { + val homeDataBuilder = stores.getStore(source) + checkNotNull(homeDataBuilder) { "Could not find store for $source" } + homeDataBuilder.getHomeScreenData(list) + } + + setHomeErrorMessage(result.getResultStatus(), source) + list.sortBy { + when (it.source) { + APP_TYPE_OPEN -> AppSourceWeight.OPEN_SOURCE.ordinal + APP_TYPE_PWA -> AppSourceWeight.PWA.ordinal + else -> AppSourceWeight.GPLAY.ordinal + } + } + + return ResultSupreme.create(result.getResultStatus(), list) + } + + private fun setHomeErrorMessage(apiStatus: ResultStatus, source: Source) { + if (apiStatus != ResultStatus.OK) { + apiStatus.message = when (source) { + Source.PLAY_STORE -> ("GPlay home loading error\n" + apiStatus.message).trim() + Source.SYSTEM_APP -> ("Gitlab home not allowed\n" + apiStatus.message).trim() + Source.OPEN_SOURCE -> ("Open Source home loading error\n" + apiStatus.message).trim() + Source.PWA -> ("PWA home loading error\n" + apiStatus.message).trim() + } + } + } + + private fun mergeResults(results: Collection>>): List { + val merged = results.flatMap { it.data.orEmpty() }.toMutableList() + merged.sortBy { + when (it.source) { + APP_TYPE_OPEN -> AppSourceWeight.OPEN_SOURCE.ordinal + APP_TYPE_PWA -> AppSourceWeight.PWA.ordinal + else -> AppSourceWeight.GPLAY.ordinal + } + } + return merged + } fun getSelectedAppTypes(): List { val selectedAppTypes = mutableListOf() diff --git a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt index 13cedfb0283ba4e6656accdab9ed634670a35077..924a5666f9791ef0b90b00e395eb4bbaf47e331d 100644 --- a/app/src/main/java/foundation/e/apps/data/application/data/Application.kt +++ b/app/src/main/java/foundation/e/apps/data/application/data/Application.kt @@ -39,6 +39,7 @@ data class Application( var perms: List = emptyList(), var reportId: Long = -1L, val icon_image_path: String = String(), + val icon_url: String = String(), val last_modified: String = String(), var latest_version_code: Long = -1, val latest_version_number: String = String(), @@ -102,6 +103,9 @@ data class Application( ) { val iconUrl: String? get() { + if (icon_url.isNotBlank()) { + return icon_url + } if (icon_image_path.isBlank()) { return null } diff --git a/app/src/main/java/foundation/e/apps/data/application/home/HomeApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/home/HomeApiImpl.kt deleted file mode 100644 index 72ce4924e2024fdf2a9a4a7572fd6d2c2bcfa338..0000000000000000000000000000000000000000 --- a/app/src/main/java/foundation/e/apps/data/application/home/HomeApiImpl.kt +++ /dev/null @@ -1,98 +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.application.home - -import androidx.lifecycle.LiveData -import androidx.lifecycle.liveData -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.Stores -import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.application.data.Home -import foundation.e.apps.data.enums.ResultStatus -import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.handleNetworkResult -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import javax.inject.Inject - -class HomeApiImpl @Inject constructor( - private val stores: Stores -) : HomeApi { - private enum class AppSourceWeight { - GPLAY, - OPEN_SOURCE, - PWA - } - - override suspend fun fetchHomeScreenData(): LiveData>> { - val list = mutableListOf() - - return liveData { - coroutineScope { - if (Source.PLAY_STORE in stores.getStores()) { - val result = async { - loadHomeData(list, Source.PLAY_STORE) - } - emit(result.await()) - } - - val otherStores = stores.getStores().filter { it.key != Source.PLAY_STORE } - otherStores.forEach { (source, _) -> - val result = async { - loadHomeData(list, source) - } - emit(result.await()) - } - } - } - } - - private suspend fun loadHomeData( - priorList: MutableList, - source: Source - ): ResultSupreme> { - val result = handleNetworkResult { - val homeDataBuilder = stores.getStore(source) - homeDataBuilder?.getHomeScreenData(priorList) - ?: throw IllegalStateException("Could not find store for $source") - } - - setHomeErrorMessage(result.getResultStatus(), source) - priorList.sortBy { - when (it.source) { - ApplicationRepository.APP_TYPE_OPEN -> AppSourceWeight.OPEN_SOURCE.ordinal - ApplicationRepository.APP_TYPE_PWA -> AppSourceWeight.PWA.ordinal - else -> AppSourceWeight.GPLAY.ordinal - } - } - - return ResultSupreme.create(result.getResultStatus(), priorList) - } - - private fun setHomeErrorMessage(apiStatus: ResultStatus, source: Source) { - if (apiStatus != ResultStatus.OK) { - apiStatus.message = when (source) { - Source.PLAY_STORE -> ("GPlay home loading error\n" + apiStatus.message).trim() - Source.SYSTEM_APP -> ("Gitlab home not allowed\n" + apiStatus.message).trim() - Source.OPEN_SOURCE -> ("Open Source home loading error\n" + apiStatus.message).trim() - Source.PWA -> ("PWA home loading error\n" + apiStatus.message).trim() - } - } - } -} diff --git a/app/src/main/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapper.kt b/app/src/main/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..22ac25a743e4a402e6a4a0dfbfe6ebcc67d9b0eb --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapper.kt @@ -0,0 +1,67 @@ +/* + * 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.application.mapper + +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.domain.application.ApplicationDomain + +fun ApplicationDomain.toApplication() = Application( + _id = id, + author = author, + category = category, + description = description, + perms = perms, + reportId = reportId, + icon_image_path = iconImagePath, + icon_url = iconUrl.orEmpty(), + last_modified = lastModified, + latest_version_code = latestVersionCode, + latest_version_number = latestVersionNumber, + latest_downloaded_version = latestDownloadedVersion, + licence = licence, + name = name, + other_images_path = otherImagesPath, + package_name = packageName, + offer_type = offerType, + status = status, + shareUrl = shareUrl, + originalSize = originalSize, + appSize = appSize, + source = source, + price = price, + isFree = isFree, + is_pwa = isPwa, + pwaPlayerDbId = pwaPlayerDbId, + url = url, + type = type, + privacyScore = privacyScore, + isPurchased = isPurchased, + updatedOn = updatedOn, + numberOfPermission = numberOfPermission, + numberOfTracker = numberOfTracker, + filterLevel = filterLevel, + isGplayReplaced = isGplayReplaced, + isFDroidApp = isFDroidApp, + contentRating = contentRating, + restriction = restriction, + antiFeatures = antiFeatures, + isPlaceHolder = isPlaceHolder, + ratings = ratings, + isSystemApp = isSystemApp, +) diff --git a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt index f8ece5d7722c2cd05e73320aa05baebfff424fba..25540ddc4dd63e3cface7d953ea102752694a242 100644 --- a/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt @@ -37,6 +37,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R import foundation.e.apps.data.StoreRepository import foundation.e.apps.data.application.ApplicationDataManager +import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Home import foundation.e.apps.data.application.search.SearchSuggestion @@ -51,6 +52,9 @@ import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.utils.SystemInfoProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import timber.log.Timber import java.net.HttpURLConnection @@ -66,31 +70,36 @@ class PlayStoreRepository @Inject constructor( private val playStoreSearchHelper: PlayStoreSearchHelper ) : StoreRepository { - override suspend fun getHomeScreenData(list: MutableList): List { - val homeScreenData = mutableMapOf>() + override suspend fun getHomeScreenData(list: MutableList) = coroutineScope { val homeElements = createTopChartElements() - homeElements.forEach { - if (it.value.isEmpty()) return@forEach + val results = homeElements.map { (title, chartMap) -> + async { + if (chartMap.isEmpty()) { + return@async null + } + val chart = chartMap.keys.iterator().next() + val type = chartMap.values.iterator().next() + title to getTopApps(type, chart) + } + }.awaitAll().filterNotNull() - val chart = it.value.keys.iterator().next() - val type = it.value.values.iterator().next() - val result = getTopApps(type, chart) - homeScreenData[it.key] = result - } + results.forEach { (title, apps) -> + if (apps.isEmpty()) { + return@forEach + } - homeScreenData.map { - val fusedApps = it.value.map { app -> + val fusedApps = apps.map { app -> app.apply { applicationDataManager.updateStatus(this) applicationDataManager.updateFilterLevel(this) source = Source.PLAY_STORE } } - list.add(Home(it.key, fusedApps)) + list.add(Home(title, fusedApps, ApplicationRepository.APP_TYPE_ANY)) } - return list + list } private fun createTopChartElements() = mutableMapOf( diff --git a/app/src/main/java/foundation/e/apps/di/DataModule.kt b/app/src/main/java/foundation/e/apps/di/DataModule.kt index bc916003c44eb400b3a1792951acc148f87e0f6d..5d0d905768aa6669b35dfdd2304b22e5d52a4daf 100644 --- a/app/src/main/java/foundation/e/apps/di/DataModule.kt +++ b/app/src/main/java/foundation/e/apps/di/DataModule.kt @@ -28,8 +28,6 @@ import foundation.e.apps.data.application.category.CategoryApi import foundation.e.apps.data.application.category.CategoryApiImpl import foundation.e.apps.data.application.downloadInfo.DownloadInfoApi import foundation.e.apps.data.application.downloadInfo.DownloadInfoApiImpl -import foundation.e.apps.data.application.home.HomeApi -import foundation.e.apps.data.application.home.HomeApiImpl import foundation.e.apps.data.application.search.SearchRepository import foundation.e.apps.data.application.search.SearchRepositoryImpl import javax.inject.Singleton @@ -38,10 +36,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) interface DataModule { - @Singleton - @Binds - fun getHomeApi(homeApiImpl: HomeApiImpl): HomeApi - @Singleton @Binds fun getCategoryApi(categoryApiImpl: CategoryApiImpl): CategoryApi diff --git a/app/src/main/java/foundation/e/apps/domain/application/ApplicationDomain.kt b/app/src/main/java/foundation/e/apps/domain/application/ApplicationDomain.kt new file mode 100644 index 0000000000000000000000000000000000000000..0bea64fb45eb53ffcba674e7439c3f3cce0b4c3c --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/application/ApplicationDomain.kt @@ -0,0 +1,72 @@ +/* + * 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.domain.application + +import com.aurora.gplayapi.Constants.Restriction +import com.aurora.gplayapi.data.models.ContentRating +import foundation.e.apps.data.application.data.Ratings +import foundation.e.apps.data.enums.FilterLevel +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status +import foundation.e.apps.data.enums.Type + +data class ApplicationDomain( + val id: String = String(), + val author: String = String(), + val category: String = String(), + val description: String = String(), + val perms: List = emptyList(), + val reportId: Long = -1L, + val packageName: String = String(), + val name: String = String(), + val iconImagePath: String = String(), + val iconUrl: String? = null, + val otherImagesPath: List = emptyList(), + val lastModified: String = String(), + val latestVersionCode: Long = -1, + val latestVersionNumber: String = String(), + val latestDownloadedVersion: String = String(), + val licence: String = String(), + val source: Source = Source.PLAY_STORE, + val status: Status = Status.UNAVAILABLE, + val ratings: Ratings = Ratings(), + val offerType: Int = -1, + val isFree: Boolean = true, + val price: String = String(), + val isPwa: Boolean = false, + val url: String = String(), + val pwaPlayerDbId: Long = -1L, + val type: Type = Type.NATIVE, + val originalSize: Long = 0, + val appSize: String = String(), + val shareUrl: String = String(), + val privacyScore: Int = -1, + val isPurchased: Boolean = false, + val updatedOn: String = String(), + val numberOfPermission: Int = 0, + val numberOfTracker: Int = 0, + val contentRating: ContentRating = ContentRating(), + val restriction: Restriction = Restriction.NOT_RESTRICTED, + val filterLevel: FilterLevel = FilterLevel.UNKNOWN, + val isGplayReplaced: Boolean = false, + val isFDroidApp: Boolean = false, + val antiFeatures: List> = emptyList(), + val isPlaceHolder: Boolean = false, + val isSystemApp: Boolean = false, +) diff --git a/app/src/main/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCase.kt b/app/src/main/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCase.kt new file mode 100644 index 0000000000000000000000000000000000000000..16924f26dbf2fd4dbe39394d52f36cbb3ead46e2 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCase.kt @@ -0,0 +1,99 @@ +/* + * 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.domain.home + +import androidx.lifecycle.LiveData +import androidx.lifecycle.map +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.data.Home +import foundation.e.apps.domain.application.ApplicationDomain +import javax.inject.Inject + +class FetchHomeScreenDataUseCase @Inject constructor( + private val applicationRepository: ApplicationRepository, +) { + operator fun invoke(): LiveData { + return applicationRepository.getHomeScreenData().map { result -> + val homeSections = result.data?.map { it.toDomain() }.orEmpty() + when (result) { + is ResultSupreme.Success -> HomeScreenResult.Success(homeSections) + is ResultSupreme.Timeout -> HomeScreenResult.Timeout(homeSections) + is ResultSupreme.Error -> HomeScreenResult.Error( + homeSections, + result.message, + result.exception + ) + } + } + } + + private fun Home.toDomain() = HomeSection( + title, + list.map { it.toDomain() }, + source, + id, + ) + + private fun Application.toDomain() = ApplicationDomain( + id = _id, + author = author, + category = category, + description = description, + perms = perms, + reportId = reportId, + iconImagePath = icon_image_path, + iconUrl = iconUrl, + name = name, + otherImagesPath = other_images_path, + lastModified = last_modified, + latestVersionCode = latest_version_code, + latestVersionNumber = latest_version_number, + latestDownloadedVersion = latest_downloaded_version, + licence = licence, + packageName = package_name, + source = source, + price = price, + isFree = isFree, + isPwa = is_pwa, + pwaPlayerDbId = pwaPlayerDbId, + url = url, + status = status, + ratings = ratings, + offerType = offer_type, + type = type, + originalSize = originalSize, + appSize = appSize, + shareUrl = shareUrl, + privacyScore = privacyScore, + isPurchased = isPurchased, + updatedOn = updatedOn, + numberOfPermission = numberOfPermission, + numberOfTracker = numberOfTracker, + restriction = restriction, + isPlaceHolder = isPlaceHolder, + filterLevel = filterLevel, + isGplayReplaced = isGplayReplaced, + contentRating = contentRating, + isFDroidApp = isFDroidApp, + antiFeatures = antiFeatures, + isSystemApp = isSystemApp, + ) +} diff --git a/app/src/main/java/foundation/e/apps/domain/home/HomeScreenResult.kt b/app/src/main/java/foundation/e/apps/domain/home/HomeScreenResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..6dbbcc7772ac4c0e84804fa0bafa2f3cfadf4882 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/home/HomeScreenResult.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.domain.home + +sealed class HomeScreenResult { + abstract val data: List + + data class Success(override val data: List) : HomeScreenResult() + + data class Timeout(override val data: List) : HomeScreenResult() + + data class Error( + override val data: List, + val message: String, + val exception: Exception?, + ) : HomeScreenResult() + + fun isSuccess() = this is Success +} diff --git a/app/src/main/java/foundation/e/apps/data/application/home/HomeApi.kt b/app/src/main/java/foundation/e/apps/domain/home/HomeSection.kt similarity index 63% rename from app/src/main/java/foundation/e/apps/data/application/home/HomeApi.kt rename to app/src/main/java/foundation/e/apps/domain/home/HomeSection.kt index 51185eb28533ed777f285cb5b10b6a344d8782eb..e94b6bbe9562f1098bb5f4dcf4ed48cb5d4bc840 100644 --- a/app/src/main/java/foundation/e/apps/data/application/home/HomeApi.kt +++ b/app/src/main/java/foundation/e/apps/domain/home/HomeSection.kt @@ -1,6 +1,5 @@ /* - * Copyright MURENA SAS 2023 - * Apps Quickly and easily install Android apps onto your device! + * Copyright (C) MURENA SAS 2026 * * 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 @@ -14,14 +13,17 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . + * */ -package foundation.e.apps.data.application.home +package foundation.e.apps.domain.home -import androidx.lifecycle.LiveData -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.application.data.Home +import foundation.e.apps.domain.application.ApplicationDomain +import java.util.UUID -interface HomeApi { - suspend fun fetchHomeScreenData(): LiveData>> -} +data class HomeSection( + val title: String = String(), + val apps: List = emptyList(), + val source: String = String(), + val id: String = UUID.randomUUID().toString(), +) diff --git a/app/src/main/java/foundation/e/apps/ui/AppInfoFetchViewModel.kt b/app/src/main/java/foundation/e/apps/ui/AppInfoFetchViewModel.kt index d1a73dfe6b24a3e2ede92504f1b738ee529bcbdc..8040017ba72cbf2400c3208573aed532463a2295 100644 --- a/app/src/main/java/foundation/e/apps/ui/AppInfoFetchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/AppInfoFetchViewModel.kt @@ -5,10 +5,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.liveData import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.mapper.toApplication import foundation.e.apps.data.blockedApps.BlockedAppRepository import foundation.e.apps.data.faultyApps.FaultyAppRepository import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.playstore.PlayStoreRepository +import foundation.e.apps.domain.application.ApplicationDomain import javax.inject.Inject /** @@ -46,6 +48,10 @@ class AppInfoFetchViewModel @Inject constructor( } } + fun isAppPurchased(homeApp: ApplicationDomain): LiveData { + return isAppPurchased(homeApp.toApplication()) + } + fun isAppInBlockedList(application: Application): Boolean { return blockedAppRepository.getBlockedAppPackages().contains(application.package_name) } diff --git a/app/src/main/java/foundation/e/apps/ui/AppProgressViewModel.kt b/app/src/main/java/foundation/e/apps/ui/AppProgressViewModel.kt index 01a5dd5c142e8d9402723b7455e2b300ee05a859..6deb29e8f0832f8611a1b883db2f69569e130ee3 100644 --- a/app/src/main/java/foundation/e/apps/ui/AppProgressViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/AppProgressViewModel.kt @@ -21,7 +21,9 @@ package foundation.e.apps.ui import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.mapper.toApplication import foundation.e.apps.data.install.AppManagerWrapper +import foundation.e.apps.domain.application.ApplicationDomain import foundation.e.apps.install.download.data.DownloadProgress import foundation.e.apps.install.download.data.DownloadProgressLD import javax.inject.Inject @@ -40,4 +42,11 @@ class AppProgressViewModel @Inject constructor( ): Int { return appManagerWrapper.calculateProgress(application, progress) } + + suspend fun calculateProgress( + homeApp: ApplicationDomain?, + progress: DownloadProgress + ): Int { + return appManagerWrapper.calculateProgress(homeApp?.toApplication(), progress) + } } 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 399eeb87f793ecfd57ab54ed30126975c8485f86..edb824d9164f2551d758a23ab3015fd3cc1ee3c6 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -30,6 +30,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.R import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.mapper.toApplication import foundation.e.apps.data.blockedApps.BlockedAppRepository import foundation.e.apps.data.enums.User import foundation.e.apps.data.enums.isInitialized @@ -42,8 +43,9 @@ import foundation.e.apps.data.login.state.LoginState import foundation.e.apps.data.parentalcontrol.fdroid.FDroidAntiFeatureRepository import foundation.e.apps.data.parentalcontrol.googleplay.GPlayContentRatingRepository import foundation.e.apps.data.preference.AppLoungeDataStore -import foundation.e.apps.data.preference.getSync -import foundation.e.apps.domain.login.ReportFaultyTokenUseCase +import foundation.e.apps.data.preference.getSync +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.login.ReportFaultyTokenUseCase import foundation.e.apps.install.pkg.AppLoungePackageManager import foundation.e.apps.install.pkg.PwaManager import foundation.e.apps.install.workmanager.AppInstallProcessor @@ -62,10 +64,10 @@ class MainActivityViewModel @Inject constructor( private val blockedAppRepository: BlockedAppRepository, private val gPlayContentRatingRepository: GPlayContentRatingRepository, private val fDroidAntiFeatureRepository: FDroidAntiFeatureRepository, - private val appInstallProcessor: AppInstallProcessor, - private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, - private val reportFaultyTokenUseCase: ReportFaultyTokenUseCase, -) : ViewModel() { + private val appInstallProcessor: AppInstallProcessor, + private val systemAppsUpdatesRepository: SystemAppsUpdatesRepository, + private val reportFaultyTokenUseCase: ReportFaultyTokenUseCase, +) : ViewModel() { init { updateAppWarningList() @@ -124,15 +126,15 @@ class MainActivityViewModel @Inject constructor( if (authObjects.isEmpty()) { handleEmptyAuthObjects() return - } - + } + val shouldFinishAfterLogin = shouldFinishAfterLogin(authObjects) val user = getUser() if (user == User.ANONYMOUS) { viewModelScope.launch { reportFaultyTokenUseCase(authObjects) - } - } + } + } if (shouldFinishAfterLogin) { _uiState.value = _uiState.value?.copy(finishAfterLogin = true) @@ -191,6 +193,10 @@ class MainActivityViewModel @Inject constructor( return false } + fun shouldShowPaidAppsSnackBar(app: ApplicationDomain): Boolean { + return shouldShowPaidAppsSnackBar(app.toApplication()) + } + /** * Handle various cases of unsupported apps here. * Returns true if the [application] is not supported by App Lounge. @@ -222,6 +228,28 @@ class MainActivityViewModel @Inject constructor( return false } + fun checkUnsupportedApplication( + homeApp: ApplicationDomain, + alertDialogContext: Context? = null + ): Boolean { + if (!homeApp.filterLevel.isUnFiltered()) { + alertDialogContext?.let { context -> + AlertDialog.Builder(context).apply { + setTitle(R.string.unsupported_app_title) + setMessage( + context.getString( + R.string.unsupported_app_unreleased, + homeApp.name + ) + ) + setPositiveButton(android.R.string.ok, null) + }.show() + } + return true + } + return false + } + /** * Fetch the filter level of an app and perform some action. * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5720 @@ -247,6 +275,10 @@ class MainActivityViewModel @Inject constructor( } } + fun getApplication(homeApp: ApplicationDomain) { + getApplication(homeApp.toApplication()) + } + suspend fun updateAwaitingForPurchasedApp(packageName: String): AppInstall? { val fusedDownload = appManagerWrapper.getFusedDownload(packageName = packageName) val authData = appLoungeDataStore.getAuthData() @@ -271,6 +303,10 @@ class MainActivityViewModel @Inject constructor( } } + fun cancelDownload(homeApp: ApplicationDomain) { + cancelDownload(homeApp.toApplication()) + } + fun setupConnectivityManager(context: Context) { internetConnection = NetworkStatusManager.init(context) } @@ -289,6 +325,21 @@ class MainActivityViewModel @Inject constructor( } } + fun updateStatusOfApplicationDomains( + homeApps: List, + appInstallList: List + ): List { + return homeApps.map { homeApp -> + val downloadingItem = appInstallList.find { fusedDownload -> + fusedDownload.source == homeApp.source && + (fusedDownload.packageName == homeApp.packageName || fusedDownload.id == homeApp.id) + } + val status = downloadingItem?.status + ?: applicationRepository.getFusedAppInstallationStatus(homeApp.toApplication()) + homeApp.copy(status = status) + } + } + fun updateAppWarningList() { viewModelScope.launch { blockedAppRepository.fetchUpdateOfAppWarningList() @@ -320,6 +371,10 @@ class MainActivityViewModel @Inject constructor( pwaManager.launchPwa(application) } + fun launchPwa(homeApp: ApplicationDomain) { + launchPwa(homeApp.toApplication()) + } + fun handleRatingFormat(rating: Double): String? { return appManagerWrapper.handleRatingFormat(rating) } diff --git a/app/src/main/java/foundation/e/apps/ui/home/HomeFragment.kt b/app/src/main/java/foundation/e/apps/ui/home/HomeFragment.kt index 989be8c6486544a12bded0a2445a7519ee7db29c..96f1ef4ecc020d2a643857b03a5454113d85587e 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/HomeFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/HomeFragment.kt @@ -28,12 +28,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint 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.enums.Status import foundation.e.apps.databinding.FragmentHomeBinding +import foundation.e.apps.domain.application.ApplicationDomain import foundation.e.apps.install.download.data.DownloadProgress -import foundation.e.apps.install.pkg.PwaManager import foundation.e.apps.ui.AppInfoFetchViewModel import foundation.e.apps.ui.AppProgressViewModel import foundation.e.apps.ui.MainActivityViewModel @@ -42,10 +40,9 @@ import foundation.e.apps.ui.home.model.HomeChildRVAdapter import foundation.e.apps.ui.home.model.HomeParentRVAdapter import kotlinx.coroutines.launch import java.util.Locale -import javax.inject.Inject @AndroidEntryPoint -class HomeFragment : Fragment(R.layout.fragment_home), ApplicationInstaller { +class HomeFragment : Fragment(R.layout.fragment_home) { /* * Make adapter nullable to avoid memory leaks. @@ -60,9 +57,6 @@ class HomeFragment : Fragment(R.layout.fragment_home), ApplicationInstaller { private val appProgressViewModel: AppProgressViewModel by viewModels() private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() - @Inject - lateinit var pwaManager: PwaManager - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentHomeBinding.bind(view) @@ -86,24 +80,25 @@ class HomeFragment : Fragment(R.layout.fragment_home), ApplicationInstaller { return@observe } - homeParentRVAdapter?.setData(it.data!!) + homeParentRVAdapter?.setData(it.data) } } - private fun initHomeParentRVAdapter() = HomeParentRVAdapter( - this, - mainActivityViewModel, - appInfoFetchViewModel, - viewLifecycleOwner + private fun initHomeParentRVAdapter() = HomeParentRVAdapter( + mainActivityViewModel, + appInfoFetchViewModel, + viewLifecycleOwner, + { installApplication(it) }, + { cancelDownload(it) }, ) { fusedApp -> if (!mainActivityViewModel.shouldShowPaidAppsSnackBar(fusedApp)) { showPaidAppMessage(fusedApp) } - } - - private fun showPaidAppMessage(application: Application) { - ApplicationDialogFragment( - title = getString(R.string.dialog_title_paid_app, application.name), + } + + private fun showPaidAppMessage(application: ApplicationDomain) { + ApplicationDialogFragment( + title = getString(R.string.dialog_title_paid_app, application.name), message = getString( R.string.dialog_paidapp_message, application.name, @@ -176,8 +171,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), ApplicationInstaller { return@forEach } - val progress = - appProgressViewModel.calculateProgress(fusedApp, downloadProgress) + val progress = appProgressViewModel.calculateProgress(fusedApp, downloadProgress) if (progress == -1) { return@forEach } @@ -213,11 +207,11 @@ class HomeFragment : Fragment(R.layout.fragment_home), ApplicationInstaller { homeParentRVAdapter = null } - override fun installApplication(app: Application) { + private fun installApplication(app: ApplicationDomain) { mainActivityViewModel.getApplication(app) } - override fun cancelDownload(app: Application) { + private fun cancelDownload(app: ApplicationDomain) { mainActivityViewModel.cancelDownload(app) } } diff --git a/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt b/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt index 85f1ce654f6d860d1c6bf028845d2f1899d87c08..cca7cb73059d3bd03dbfe3046a08bc5c71c1f7b6 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/HomeViewModel.kt @@ -24,35 +24,31 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.StoreRepository import foundation.e.apps.data.Stores -import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.application.data.Home import foundation.e.apps.data.enums.Source -import foundation.e.apps.data.preference.AppLoungePreference -import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil +import foundation.e.apps.domain.home.FetchHomeScreenDataUseCase +import foundation.e.apps.domain.home.HomeScreenResult +import foundation.e.apps.domain.home.HomeSection +import foundation.e.apps.ui.home.model.ApplicationDomainDiffUtil import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val applicationRepository: ApplicationRepository, + private val fetchHomeScreenDataUseCase: FetchHomeScreenDataUseCase, private val stores: Stores ) : ViewModel() { - @Inject - lateinit var appLoungePreference: AppLoungePreference - /* * Hold list of applications, as well as application source type. * Source type may change from user selected preference in case of timeout. * * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5404 */ - var homeScreenData: MutableLiveData>> = MutableLiveData() + var homeScreenData = MutableLiveData() - var currentHomes: List? = null + var currentHomes = emptyList() private var previousStores = mapOf() @@ -72,7 +68,7 @@ class HomeViewModel @Inject constructor( fun getHomeScreenData(lifecycleOwner: LifecycleOwner) { viewModelScope.launch { - applicationRepository.getHomeScreenData().observe(lifecycleOwner) { + fetchHomeScreenDataUseCase().observe(lifecycleOwner) { postHomeResult(it) if (it.isSuccess()) { @@ -82,33 +78,33 @@ class HomeViewModel @Inject constructor( } } - private fun postHomeResult(homeResult: ResultSupreme>) { + private fun postHomeResult(homeResult: HomeScreenResult) { if (shouldUpdateResult(homeResult)) { homeScreenData.value = homeResult // Here, homeResult.data is a mutableList which can be changed anytime. // That's why we're setting copy of the list, so that currentHomes isn't changed, // when homeresult.data is changed. - currentHomes = homeResult.data?.map { it.copy() } + currentHomes = homeResult.data.map { it.deepCopy() } return } } - private fun shouldUpdateResult(homeResult: ResultSupreme>) = - (homeResult.isSuccess() && hasAnyChange(homeResult.data!!)) || !homeResult.isSuccess() + private fun shouldUpdateResult(homeResult: HomeScreenResult) = + (homeResult.isSuccess() && hasAnyChange(homeResult.data)) || !homeResult.isSuccess() @VisibleForTesting fun hasAnyChange( - newHomes: List, - ) = currentHomes.isNullOrEmpty() || newHomes.size != currentHomes!!.size || compareWithNewData( + newHomes: List, + ) = currentHomes.isEmpty() || newHomes.size != currentHomes.size || compareWithNewData( newHomes ) - private fun compareWithNewData(newHomes: List): Boolean { - currentHomes?.forEach { - val fusedHome = newHomes[currentHomes!!.indexOf(it)] + private fun compareWithNewData(newHomes: List): Boolean { + currentHomes.forEachIndexed { index, home -> + val fusedHome = newHomes[index] - if (!it.title.contentEquals(fusedHome.title) || !it.id.contentEquals(fusedHome.id) - || areFusedAppsUpdated(it, fusedHome) + if (!home.title.contentEquals(fusedHome.title) || !home.id.contentEquals(fusedHome.id) + || areFusedAppsUpdated(home, fusedHome) ) { return true } @@ -118,19 +114,19 @@ class HomeViewModel @Inject constructor( } private fun areFusedAppsUpdated( - oldHome: Home, - newHome: Home, - ) = oldHome.list.size != newHome.list.size || hasAppListsAnyChange(oldHome, newHome) + oldHome: HomeSection, + newHome: HomeSection, + ) = oldHome.apps.size != newHome.apps.size || hasAppListsAnyChange(oldHome, newHome) private fun hasAppListsAnyChange( - oldHome: Home, - newHome: Home, + oldHome: HomeSection, + newHome: HomeSection, ): Boolean { - val fusedAppDiffUtil = ApplicationDiffUtil() + val fusedAppDiffUtil = ApplicationDomainDiffUtil() - oldHome.list.forEach { oldFusedApp -> - val indexOfOldFusedApp = oldHome.list.indexOf(oldFusedApp) - val fusedApp = newHome.list[indexOfOldFusedApp] + oldHome.apps.forEach { oldFusedApp -> + val indexOfOldFusedApp = oldHome.apps.indexOf(oldFusedApp) + val fusedApp = newHome.apps[indexOfOldFusedApp] if (!fusedAppDiffUtil.areContentsTheSame(oldFusedApp, fusedApp)) { return true @@ -139,4 +135,6 @@ class HomeViewModel @Inject constructor( return false } + + private fun HomeSection.deepCopy() = copy(apps = apps.map { it.copy() }) } diff --git a/app/src/main/java/foundation/e/apps/ui/home/model/ApplicationDomainDiffUtil.kt b/app/src/main/java/foundation/e/apps/ui/home/model/ApplicationDomainDiffUtil.kt new file mode 100644 index 0000000000000000000000000000000000000000..a400f56debc885c673b3ffe8241e27d39bc961a6 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/home/model/ApplicationDomainDiffUtil.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) MURENA SAS 2026 + * + * 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.ui.home.model + +import androidx.recyclerview.widget.DiffUtil +import foundation.e.apps.domain.application.ApplicationDomain + +class ApplicationDomainDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ApplicationDomain, newItem: ApplicationDomain): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ApplicationDomain, newItem: ApplicationDomain): Boolean { + return oldItem == newItem + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/home/model/FusedHomeDiffUtil.kt b/app/src/main/java/foundation/e/apps/ui/home/model/FusedHomeDiffUtil.kt index c6d4491dc7d7d35c99633a5537ee349f0e74924e..ecf7b8826bfb29e96588aa6066415fa1014bc09e 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/model/FusedHomeDiffUtil.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/model/FusedHomeDiffUtil.kt @@ -19,17 +19,17 @@ package foundation.e.apps.ui.home.model import android.annotation.SuppressLint import androidx.recyclerview.widget.DiffUtil -import foundation.e.apps.data.application.data.Home +import foundation.e.apps.domain.home.HomeSection -class FusedHomeDiffUtil : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Home, newItem: Home): Boolean { - return oldItem.list == newItem.list +class FusedHomeDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: HomeSection, newItem: HomeSection): Boolean { + return oldItem.apps == newItem.apps } @SuppressLint("DiffUtilEquals") - override fun areContentsTheSame(oldItem: Home, newItem: Home): Boolean { + override fun areContentsTheSame(oldItem: HomeSection, newItem: HomeSection): Boolean { return oldItem.title.contentEquals(newItem.title) && - oldItem.list == newItem.list && + oldItem.apps == newItem.apps && oldItem.id == newItem.id } } diff --git a/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt index 8c0155fb2876e9f858190e71225b331f8507db31..0ead5aac1aa7a745f842cc9aea2bf2ea2b8fa430 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/model/HomeChildRVAdapter.kt @@ -32,26 +32,25 @@ import com.facebook.shimmer.ShimmerDrawable import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar 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.enums.Status import foundation.e.apps.data.enums.User import foundation.e.apps.data.login.state.LoginState import foundation.e.apps.databinding.HomeChildListItemBinding +import foundation.e.apps.domain.application.ApplicationDomain import foundation.e.apps.ui.AppInfoFetchViewModel import foundation.e.apps.ui.MainActivityViewModel -import foundation.e.apps.ui.applicationlist.ApplicationDiffUtil import foundation.e.apps.ui.home.HomeFragmentDirections import foundation.e.apps.utils.disableInstallButton import foundation.e.apps.utils.enableInstallButton class HomeChildRVAdapter( - private var applicationInstaller: ApplicationInstaller?, private val appInfoFetchViewModel: AppInfoFetchViewModel, private val mainActivityViewModel: MainActivityViewModel, private var lifecycleOwner: LifecycleOwner?, - private var paidAppHandler: ((Application) -> Unit)? = null -) : ListAdapter(ApplicationDiffUtil()) { + private val installHandler: (ApplicationDomain) -> Unit, + private val cancelHandler: (ApplicationDomain) -> Unit, + private var paidAppHandler: ((ApplicationDomain) -> Unit)? = null +) : ListAdapter(ApplicationDomainDiffUtil()) { private val shimmer = Shimmer.ColorHighlightBuilder() .setDuration(SHIMMER_DURATION_MS) @@ -86,8 +85,8 @@ class HomeChildRVAdapter( appName.text = homeApp.name homeLayout.setOnClickListener { val action = HomeFragmentDirections.actionHomeFragmentToApplicationFragment( - homeApp.package_name, - homeApp._id, + homeApp.packageName, + homeApp.id, homeApp.source, homeApp.category, homeApp.isGplayReplaced, @@ -131,7 +130,7 @@ class HomeChildRVAdapter( } private fun HomeChildListItemBinding.handleInstallationIssue( - homeApp: Application + homeApp: ApplicationDomain ) { installButton.apply { enableInstallButton() @@ -173,7 +172,7 @@ class HomeChildRVAdapter( } private fun HomeChildListItemBinding.handleQueued( - homeApp: Application + homeApp: ApplicationDomain ) { installButton.apply { enableInstallButton() @@ -194,7 +193,7 @@ class HomeChildRVAdapter( } private fun HomeChildListItemBinding.handleUnavailable( - homeApp: Application, + homeApp: ApplicationDomain, holder: ViewHolder, ) { installButton.apply { @@ -215,7 +214,7 @@ class HomeChildRVAdapter( } private fun HomeChildListItemBinding.handleUpdatable( - homeApp: Application + homeApp: ApplicationDomain ) { installButton.apply { enableInstallButton(Status.UPDATABLE) @@ -235,16 +234,16 @@ class HomeChildRVAdapter( } private fun HomeChildListItemBinding.handleInstalled( - homeApp: Application + homeApp: ApplicationDomain ) { installButton.apply { enableInstallButton(Status.INSTALLED) text = context.getString(R.string.open) setOnClickListener { - if (homeApp.is_pwa) { + if (homeApp.isPwa) { mainActivityViewModel.launchPwa(homeApp) } else { - mainActivityViewModel.getLaunchIntentForPackageName(homeApp.package_name)?.let { + mainActivityViewModel.getLaunchIntentForPackageName(homeApp.packageName)?.let { context.startActivity(it) } } @@ -254,7 +253,7 @@ class HomeChildRVAdapter( } private fun updateUIByPaymentType( - homeApp: Application, + homeApp: ApplicationDomain, materialButton: MaterialButton, homeChildListItemBinding: HomeChildListItemBinding ) { @@ -289,23 +288,22 @@ class HomeChildRVAdapter( } } - fun setData(newList: List) { - this.submitList(newList.map { it.copy() }) + fun setData(newList: List) { + submitList(newList.map { it.copy() }) } - private fun installApplication(homeApp: Application) { - applicationInstaller?.installApplication(homeApp) + private fun installApplication(homeApp: ApplicationDomain) { + installHandler(homeApp) } - private fun cancelDownload(homeApp: Application) { - applicationInstaller?.cancelDownload(homeApp) + private fun cancelDownload(homeApp: ApplicationDomain) { + cancelHandler(homeApp) } override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { super.onDetachedFromRecyclerView(recyclerView) lifecycleOwner = null paidAppHandler = null - applicationInstaller = null } companion object { diff --git a/app/src/main/java/foundation/e/apps/ui/home/model/HomeParentRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/home/model/HomeParentRVAdapter.kt index 7231e3db3d3d728a2a31797a52b978d7841b05fb..f76572103e96f64d5af253256f0633ff663a4288 100644 --- a/app/src/main/java/foundation/e/apps/ui/home/model/HomeParentRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/home/model/HomeParentRVAdapter.kt @@ -25,20 +25,20 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import foundation.e.apps.data.application.ApplicationInstaller -import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.application.data.Home import foundation.e.apps.databinding.HomeParentListItemBinding +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.home.HomeSection import foundation.e.apps.ui.AppInfoFetchViewModel import foundation.e.apps.ui.MainActivityViewModel class HomeParentRVAdapter( - private val applicationInstaller: ApplicationInstaller, private val mainActivityViewModel: MainActivityViewModel, private val appInfoFetchViewModel: AppInfoFetchViewModel, private var lifecycleOwner: LifecycleOwner?, - private val paidAppHandler: ((Application) -> Unit)? = null -) : ListAdapter(FusedHomeDiffUtil()) { + private val installHandler: (ApplicationDomain) -> Unit, + private val cancelHandler: (ApplicationDomain) -> Unit, + private val paidAppHandler: ((ApplicationDomain) -> Unit)? = null +) : ListAdapter(FusedHomeDiffUtil()) { private val viewPool = RecyclerView.RecycledViewPool() private var isDetachedFromRecyclerView = false @@ -58,20 +58,21 @@ class HomeParentRVAdapter( holder.binding.titleTV.text = fusedHome.title handleChildShimmerView(fusedHome, holder) - if (fusedHome.list.isEmpty()) { + if (fusedHome.apps.isEmpty()) { return } val homeChildRVAdapter = HomeChildRVAdapter( - applicationInstaller, appInfoFetchViewModel, mainActivityViewModel, lifecycleOwner, + installHandler, + cancelHandler, paidAppHandler ) - homeChildRVAdapter.setData(fusedHome.list) + homeChildRVAdapter.setData(fusedHome.apps) holder.binding.childRV.apply { recycledViewPool.setMaxRecycledViews(0, 0) @@ -88,8 +89,8 @@ class HomeParentRVAdapter( observeAppInstall(fusedHome, homeChildRVAdapter) } - private fun handleChildShimmerView(home: Home, holder: ViewHolder) { - if (home.list.isEmpty()) { + private fun handleChildShimmerView(home: HomeSection, holder: ViewHolder) { + if (home.apps.isEmpty()) { holder.binding.shimmerLayout.visibility = View.VISIBLE holder.binding.shimmerLayout.startShimmer() holder.binding.childRV.visibility = View.GONE @@ -102,19 +103,19 @@ class HomeParentRVAdapter( } private fun observeAppInstall( - home: Home, + home: HomeSection, homeChildRVAdapter: RecyclerView.Adapter<*>? ) { lifecycleOwner?.let { mainActivityViewModel.downloadList.observe(it) { - mainActivityViewModel.updateStatusOfFusedApps(home.list, it) - (homeChildRVAdapter as HomeChildRVAdapter).setData(home.list) + val updatedApps = mainActivityViewModel.updateStatusOfApplicationDomains(home.apps, it) + (homeChildRVAdapter as HomeChildRVAdapter).setData(updatedApps) } } } - fun setData(newList: List) { - submitList(newList.map { it.copy() }) + fun setData(newList: List) { + submitList(newList.map { it.copy(apps = it.apps.map { app -> app.copy() }) }) } override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { 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 new file mode 100644 index 0000000000000000000000000000000000000000..e1ad4b2acbcd93fd4d13f9dbcdb7f7d32aab9096 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/ApplicationRepositoryHomeTest.kt @@ -0,0 +1,381 @@ +/* + * Copyright (C) MURENA SAS 2026 + * + * 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.application + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.StoreRepository +import foundation.e.apps.data.Stores +import foundation.e.apps.data.application.apps.AppsApi +import foundation.e.apps.data.application.category.CategoriesResponse +import foundation.e.apps.data.application.category.CategoryApi +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.data.Category +import foundation.e.apps.data.application.data.Home +import foundation.e.apps.data.application.downloadInfo.DownloadInfoApi +import foundation.e.apps.data.application.search.SearchSuggestion +import foundation.e.apps.data.application.utils.CategoryType +import foundation.e.apps.data.cleanapk.data.download.Download +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.enums.Status +import foundation.e.apps.data.install.models.AppInstall +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class ApplicationRepositoryHomeTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + private val playStoreHome = Home( + title = "Play", + list = listOf(Application(name = "p1")), + source = ApplicationRepository.APP_TYPE_ANY + ) + private val ossHome = Home( + title = "OSS", + list = listOf(Application(name = "o1", source = Source.OPEN_SOURCE)), + source = ApplicationRepository.APP_TYPE_OPEN + ) + + private lateinit var applicationRepository: ApplicationRepository + private val categoryApi = mockk() + private val appsApi = mockk() + private val downloadInfoApi = mockk() + private val stores: Stores = mockk() + + @Before + fun setUp() { + applicationRepository = + ApplicationRepository(categoryApi, appsApi, downloadInfoApi, stores) + } + + @Test + fun fetchHomeScreenDataEmitsSortedSources() = runTest { + val repositories = mapOf( + Source.PLAY_STORE to fakeStore(playStoreHome), + Source.OPEN_SOURCE to fakeStore(ossHome) + ) + every { stores.getStores() } returns repositories + every { stores.getStore(Source.PLAY_STORE) } returns repositories.getValue(Source.PLAY_STORE) + every { stores.getStore(Source.OPEN_SOURCE) } returns repositories.getValue(Source.OPEN_SOURCE) + + val emissions = applicationRepository.getHomeScreenData().asFlow().take(2).toList() + + assertThat(emissions).hasSize(2) + val finalList = emissions.last().data!! + assertThat(finalList.map { it.source }) + .containsExactly(ApplicationRepository.APP_TYPE_ANY, ApplicationRepository.APP_TYPE_OPEN) + .inOrder() + } + + @Test + fun fetchHomeScreenDataEmitsPlayStoreFirst() = runTest { + val repositories = mapOf( + Source.PLAY_STORE to fakeStore(playStoreHome), + Source.OPEN_SOURCE to fakeStore(ossHome) + ) + every { stores.getStores() } returns repositories + every { stores.getStore(Source.PLAY_STORE) } returns repositories.getValue(Source.PLAY_STORE) + every { stores.getStore(Source.OPEN_SOURCE) } returns repositories.getValue(Source.OPEN_SOURCE) + + val emissions = applicationRepository.getHomeScreenData().asFlow().take(2).toList() + + val firstList = emissions.first().data!! + assertThat(firstList.map { it.source }) + .containsExactly(ApplicationRepository.APP_TYPE_ANY) + .inOrder() + } + + @Test + fun fetchHomeScreenDataSetsErrorMessage() = runTest { + val failingStore = object : StoreRepository { + override suspend fun getHomeScreenData(list: MutableList): List { + throw IllegalStateException("boom") + } + override suspend fun getAppDetails(packageName: String) = Application() + override suspend fun getSearchResults(pattern: String) = emptyList() + override suspend fun getSearchSuggestions(pattern: String) = emptyList() + } + val repositories = mapOf(Source.PLAY_STORE to failingStore) + every { stores.getStores() } returns repositories + every { stores.getStore(Source.PLAY_STORE) } returns failingStore + + val emissions = applicationRepository.getHomeScreenData().asFlow().take(1).toList() + + val result = emissions.single() + assertThat(result.isUnknownError()).isTrue() + assertThat(result.message).contains("boom") + } + + @Test + fun fetchHomeScreenDataSetsOpenSourceErrorMessage() = runTest { + val failingStore = object : StoreRepository { + override suspend fun getHomeScreenData(list: MutableList): List { + throw IllegalStateException("oss down") + } + override suspend fun getAppDetails(packageName: String) = Application() + override suspend fun getSearchResults(pattern: String) = emptyList() + override suspend fun getSearchSuggestions(pattern: String) = emptyList() + } + val repositories = linkedMapOf(Source.OPEN_SOURCE to failingStore) + every { stores.getStores() } returns repositories + every { stores.getStore(Source.OPEN_SOURCE) } returns failingStore + + val emissions = applicationRepository.getHomeScreenData().asFlow().take(1).toList() + + val result = emissions.single() + assertThat(result.isUnknownError()).isTrue() + assertThat(result.message).contains("oss down") + } + + @Test + fun fetchHomeScreenDataEmitsMergedListWhenOneSourceFails() = runTest { + val failingStore = object : StoreRepository { + override suspend fun getHomeScreenData(list: MutableList): List { + throw IllegalStateException("oss down") + } + override suspend fun getAppDetails(packageName: String) = Application() + override suspend fun getSearchResults(pattern: String) = emptyList() + override suspend fun getSearchSuggestions(pattern: String) = emptyList() + } + val repositories = linkedMapOf( + Source.PLAY_STORE to fakeStore(playStoreHome), + Source.OPEN_SOURCE to failingStore, + ) + every { stores.getStores() } returns repositories + every { stores.getStore(Source.PLAY_STORE) } returns repositories.getValue(Source.PLAY_STORE) + every { stores.getStore(Source.OPEN_SOURCE) } returns failingStore + + val emissions = applicationRepository.getHomeScreenData().asFlow().take(2).toList() + + val result = emissions.last() + assertThat(result.isUnknownError()).isTrue() + assertThat(result.message).contains("oss down") + assertThat(result.data?.map { it.source }) + .containsExactly(ApplicationRepository.APP_TYPE_ANY) + } + + @Test + fun getSelectedAppTypesReturnsEnabledStores() { + every { stores.isStoreEnabled(Source.PLAY_STORE) } returns true + every { stores.isStoreEnabled(Source.OPEN_SOURCE) } returns true + every { stores.isStoreEnabled(Source.PWA) } returns false + + val result = applicationRepository.getSelectedAppTypes() + + assertThat(result) + .containsExactly(ApplicationRepository.APP_TYPE_ANY, ApplicationRepository.APP_TYPE_OPEN) + .inOrder() + } + + @Test + fun getApplicationDetailsDelegatesToAppsApiForList() = runTest { + val expected = Pair(listOf(Application(name = "n1")), ResultStatus.OK) + val packages = listOf("pkg.one") + + coEvery { appsApi.getApplicationDetails(packages, Source.PLAY_STORE) } returns expected + + val result = applicationRepository.getApplicationDetails(packages, Source.PLAY_STORE) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getAppFilterLevelDelegatesToAppsApi() = runTest { + val app = Application(name = "app") + val expected = FilterLevel.UI + + coEvery { appsApi.getAppFilterLevel(app) } returns expected + + val result = applicationRepository.getAppFilterLevel(app) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getApplicationDetailsDelegatesToAppsApiForSingle() = runTest { + val expected = Pair(Application(name = "n1"), ResultStatus.OK) + + coEvery { appsApi.getApplicationDetails("id", "pkg", Source.PWA) } returns expected + + val result = applicationRepository.getApplicationDetails("id", "pkg", Source.PWA) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun updateFusedDownloadWithDownloadingInfoDelegatesToDownloadInfoApi() = runTest { + val appInstall = AppInstall(id = "id", packageName = "pkg") + + coEvery { downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) } returns Unit + + applicationRepository.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) + + coVerify(exactly = 1) { downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall) } + } + + @Test + fun getOSSDownloadInfoDelegatesToDownloadInfoApi() = runTest { + val expected = mockk>() + + coEvery { downloadInfoApi.getOSSDownloadInfo("id", "v1") } returns expected + + val result = applicationRepository.getOSSDownloadInfo("id", "v1") + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getCategoriesListDelegatesToCategoryApi() = runTest { + val categories = listOf(Category(title = "t1")) + val expected = listOf( + CategoriesResponse(categories, CategoryType.APPLICATION, Source.OPEN_SOURCE, ResultStatus.OK) + ) + + coEvery { categoryApi.getCategoriesList(CategoryType.APPLICATION) } returns expected + + val result = applicationRepository.getCategoriesList(CategoryType.APPLICATION) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getAppsListBasedOnCategoryUsesOpenSourceApi() = runTest { + val expected = ResultSupreme.Success(Pair(listOf(Application(name = "app")), "")) + val repository = repositoryWithCategoryApi(object : CategoryApi { + override suspend fun getCategoriesList(type: CategoryType) = emptyList() + override suspend fun getGplayAppsByCategory(category: String, pageUrl: String?) = + ResultSupreme.Error, String>>() + override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = expected + }) + + val result = repository.getAppsListBasedOnCategory("cat", null, Source.OPEN_SOURCE) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getAppsListBasedOnCategoryUsesPwaApi() = runTest { + val expected = ResultSupreme.Success(Pair(listOf(Application(name = "app")), "")) + val repository = repositoryWithCategoryApi(object : CategoryApi { + override suspend fun getCategoriesList(type: CategoryType) = emptyList() + override suspend fun getGplayAppsByCategory(category: String, pageUrl: String?) = + ResultSupreme.Error, String>>() + override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = expected + }) + + val result = repository.getAppsListBasedOnCategory("cat", null, Source.PWA) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getAppsListBasedOnCategoryUsesGplayApi() = runTest { + val expected = ResultSupreme.Success(Pair(listOf(Application(name = "app")), "next")) + val repository = repositoryWithCategoryApi(object : CategoryApi { + override suspend fun getCategoriesList(type: CategoryType) = emptyList() + override suspend fun getGplayAppsByCategory(category: String, pageUrl: String?) = expected + override suspend fun getCleanApkAppsByCategory(category: String, source: Source) = + ResultSupreme.Error, String>>() + }) + + val result = repository.getAppsListBasedOnCategory("cat", "next", Source.PLAY_STORE) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun getFusedAppInstallationStatusDelegatesToAppsApi() { + val app = Application(name = "app") + + every { appsApi.getFusedAppInstallationStatus(app) } returns Status.INSTALLED + + val result = applicationRepository.getFusedAppInstallationStatus(app) + + assertThat(result).isEqualTo(Status.INSTALLED) + } + + @Test + fun isAnyFusedAppUpdatedDelegatesToAppsApi() { + val newApps = listOf(Application(name = "n1")) + val oldApps = listOf(Application(name = "o1")) + + every { appsApi.isAnyFusedAppUpdated(newApps, oldApps) } returns true + + val result = applicationRepository.isAnyFusedAppUpdated(newApps, oldApps) + + assertThat(result).isTrue() + } + + @Test + fun isAnyAppInstallStatusChangedDelegatesToAppsApi() { + val apps = listOf(Application(name = "n1")) + + every { appsApi.isAnyAppInstallStatusChanged(apps) } returns true + + val result = applicationRepository.isAnyAppInstallStatusChanged(apps) + + assertThat(result).isTrue() + } + + @Test + fun isOpenSourceSelectedDelegatesToAppsApi() { + every { appsApi.isOpenSourceSelected() } returns false + + val result = applicationRepository.isOpenSourceSelected() + + assertThat(result).isFalse() + } + + private fun fakeStore(home: Home) = object : StoreRepository { + override suspend fun getHomeScreenData(list: MutableList): List { + list.add(home) + return list + } + + override suspend fun getAppDetails(packageName: String): Application = Application() + + override suspend fun getSearchResults(pattern: String): List = emptyList() + + override suspend fun getSearchSuggestions(pattern: String): List = emptyList() + } + + private fun repositoryWithCategoryApi(categoryApi: CategoryApi) = + ApplicationRepository(categoryApi, appsApi, downloadInfoApi, stores) +} diff --git a/app/src/test/java/foundation/e/apps/data/application/home/HomeApiImplTest.kt b/app/src/test/java/foundation/e/apps/data/application/home/HomeApiImplTest.kt deleted file mode 100644 index 0c5c05748e0d687e260d92fd5125f2826ad6eb29..0000000000000000000000000000000000000000 --- a/app/src/test/java/foundation/e/apps/data/application/home/HomeApiImplTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package foundation.e.apps.data.application.home - -import com.google.common.truth.Truth.assertThat -import foundation.e.apps.data.ResultSupreme -import foundation.e.apps.data.StoreRepository -import foundation.e.apps.data.Stores -import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.application.data.Home -import foundation.e.apps.data.application.search.SearchSuggestion -import foundation.e.apps.data.enums.Source -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import androidx.lifecycle.asFlow -import foundation.e.apps.util.MainCoroutineRule -import org.junit.Rule -import org.junit.Before -import org.junit.Test -import androidx.arch.core.executor.testing.InstantTaskExecutorRule - -@OptIn(ExperimentalCoroutinesApi::class) -class HomeApiImplTest { - - @get:Rule - val mainCoroutineRule = MainCoroutineRule() - - @get:Rule - val instantExecutorRule = InstantTaskExecutorRule() - - private val playStoreHome = Home(title = "Play", list = listOf(Application(name = "p1"))) - private val ossHome = Home( - title = "OSS", - list = listOf(Application(name = "o1", source = Source.OPEN_SOURCE)), - source = ApplicationRepository.APP_TYPE_OPEN - ) - - private lateinit var homeApi: HomeApiImpl - private val stores: Stores = mockk() - - @Before - fun setUp() { - homeApi = HomeApiImpl(stores) - } - - @Test - fun fetchHomeScreenDataEmitsSortedSources() = runTest { - val repositories = mapOf( - Source.PLAY_STORE to fakeStore(playStoreHome), - Source.OPEN_SOURCE to fakeStore(ossHome) - ) - every { stores.getStores() } returns repositories - every { stores.getStore(Source.PLAY_STORE) } returns repositories.getValue(Source.PLAY_STORE) - every { stores.getStore(Source.OPEN_SOURCE) } returns repositories.getValue(Source.OPEN_SOURCE) - - val emissions = homeApi.fetchHomeScreenData().asFlow().take(2).toList() - - assertThat(emissions).hasSize(2) - val finalList = emissions.last().data!! - assertThat(finalList.map { it.source }).containsExactly("", ApplicationRepository.APP_TYPE_OPEN).inOrder() - } - - @Test - fun fetchHomeScreenDataSetsErrorMessage() = runTest { - val failingStore = object : StoreRepository { - override suspend fun getHomeScreenData(list: MutableList): List { - throw IllegalStateException("boom") - } - override suspend fun getAppDetails(packageName: String) = Application() - override suspend fun getSearchResults(pattern: String) = emptyList() - override suspend fun getSearchSuggestions(pattern: String) = emptyList() - } - val repositories = mapOf(Source.PLAY_STORE to failingStore) - every { stores.getStores() } returns repositories - every { stores.getStore(Source.PLAY_STORE) } returns failingStore - - val emissions = homeApi.fetchHomeScreenData().asFlow().take(1).toList() - - val result = emissions.single() - assertThat(result.isUnknownError()).isTrue() - assertThat(result.message).contains("boom") - } - - private fun fakeStore(home: Home) = object : StoreRepository { - override suspend fun getHomeScreenData(list: MutableList): List { - list.add(home) - return list - } - - override suspend fun getAppDetails(packageName: String): Application = Application() - - override suspend fun getSearchResults(pattern: String): List = emptyList() - - override suspend fun getSearchSuggestions(pattern: String): List = emptyList() - } -} diff --git a/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt b/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ec0c55990be67b7749cb14c9be516ee310a22e92 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.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.application.mapper + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.enums.Status +import foundation.e.apps.domain.application.ApplicationDomain +import org.junit.Test + +class ApplicationDomainMapperTest { + + @Test + fun toApplication_maps_core_fields() { + val homeApp = ApplicationDomain( + id = "app-1", + packageName = "pkg.one", + name = "App One", + source = Source.PLAY_STORE, + status = Status.INSTALLED, + isPwa = true, + iconUrl = "https://example.com/icon.png", + perms = listOf("CAMERA"), + reportId = 12L, + licence = "GPL-3.0", + updatedOn = "2026-02-06", + numberOfPermission = 3, + numberOfTracker = 2 + ) + + val application = homeApp.toApplication() + + assertThat(application._id).isEqualTo("app-1") + assertThat(application.package_name).isEqualTo("pkg.one") + assertThat(application.name).isEqualTo("App One") + assertThat(application.source).isEqualTo(Source.PLAY_STORE) + assertThat(application.status).isEqualTo(Status.INSTALLED) + assertThat(application.is_pwa).isTrue() + assertThat(application.icon_url).isEqualTo("https://example.com/icon.png") + assertThat(application.perms).containsExactly("CAMERA") + assertThat(application.reportId).isEqualTo(12L) + assertThat(application.licence).isEqualTo("GPL-3.0") + assertThat(application.updatedOn).isEqualTo("2026-02-06") + assertThat(application.numberOfPermission).isEqualTo(3) + assertThat(application.numberOfTracker).isEqualTo(2) + } +} diff --git a/app/src/test/java/foundation/e/apps/data/cleanapk/repositories/CleanApkPwaRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/cleanapk/repositories/CleanApkPwaRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..1905b517b6e0d47e03714918bd998f4d2f713a60 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/cleanapk/repositories/CleanApkPwaRepositoryTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) MURENA SAS 2026 + * + * 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.cleanapk.repositories + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.data.Home +import foundation.e.apps.data.cleanapk.CleanApkRetrofit +import foundation.e.apps.data.cleanapk.CleanApkSearchHelper +import foundation.e.apps.data.cleanapk.data.home.CleanApkHome +import foundation.e.apps.data.cleanapk.data.home.HomeScreenResponse +import foundation.e.apps.data.enums.Source +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +class CleanApkPwaRepositoryTest { + + private val cleanApkRetrofit = mockk(relaxed = true) + private val homeConverter = mockk(relaxed = true) + private val context = mockk(relaxed = true) + private val searchHelper = mockk(relaxed = true) + + private lateinit var repository: CleanApkPwaRepository + + @Before + fun setUp() { + repository = CleanApkPwaRepository(cleanApkRetrofit, homeConverter, context, searchHelper) + } + + @Test + fun `getHomeScreenData populates list and sets sources`() = runTest { + val appOne = Application(name = "One") + val appTwo = Application(name = "Two") + val expectedHomes = listOf( + Home("Top", listOf(appOne)), + Home("Popular", listOf(appTwo)) + ) + + coEvery { + cleanApkRetrofit.getHomeScreenData( + CleanApkRetrofit.APP_TYPE_PWA, + CleanApkRetrofit.APP_SOURCE_ANY + ) + } returns Response.success(HomeScreenResponse(home = CleanApkHome())) + + coEvery { homeConverter.toGenericHome(any(), CleanApkRetrofit.APP_TYPE_PWA) } returns expectedHomes + + val inputList = mutableListOf() + val result = repository.getHomeScreenData(inputList) + + coVerify { + cleanApkRetrofit.getHomeScreenData( + CleanApkRetrofit.APP_TYPE_PWA, + CleanApkRetrofit.APP_SOURCE_ANY + ) + } + + assertThat(result).isSameInstanceAs(inputList) + assertThat(result).hasSize(2) + assertThat(result.map { it.source }).containsExactly( + ApplicationRepository.APP_TYPE_PWA, + ApplicationRepository.APP_TYPE_PWA + ) + assertThat(appOne.source).isEqualTo(Source.PWA) + assertThat(appTwo.source).isEqualTo(Source.PWA) + } + + @Test(expected = IllegalStateException::class) + fun `getHomeScreenData throws when home payload missing`() = runTest { + coEvery { + cleanApkRetrofit.getHomeScreenData( + CleanApkRetrofit.APP_TYPE_PWA, + CleanApkRetrofit.APP_SOURCE_ANY + ) + } returns Response.success(null) + + repository.getHomeScreenData(mutableListOf()) + } +} diff --git a/app/src/test/java/foundation/e/apps/data/login/MicrogLoginManagerTest.kt b/app/src/test/java/foundation/e/apps/data/login/MicrogLoginManagerTest.kt index a25b6b66db4ffbdf3b15544f11f1898052effadf..920f8a84eb51e2645b6f47499763ae3deb0cf6ad 100644 --- a/app/src/test/java/foundation/e/apps/data/login/MicrogLoginManagerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/login/MicrogLoginManagerTest.kt @@ -35,7 +35,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -54,7 +53,6 @@ class MicrogLoginManagerTest { private val context = RuntimeEnvironment.getApplication() @Test - @Ignore("Flaky under mock AccountManager; covered by error-path tests") fun `returns success when token available`() = runBlocking { val account = Account("user@gmail.com", MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) val accountManager = mock() diff --git a/app/src/test/java/foundation/e/apps/data/playstore/PlayStoreRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/playstore/PlayStoreRepositoryTest.kt index 8c067fc934c5e90b9400777f3a569fcb420c1fae..d1616d8a5663f4534174c1b522d990a969c4679b 100644 --- a/app/src/test/java/foundation/e/apps/data/playstore/PlayStoreRepositoryTest.kt +++ b/app/src/test/java/foundation/e/apps/data/playstore/PlayStoreRepositoryTest.kt @@ -3,24 +3,34 @@ package foundation.e.apps.data.playstore import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider +import com.aurora.gplayapi.Constants.Restriction import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.exceptions.InternalException +import com.aurora.gplayapi.helpers.web.WebTopChartsHelper import com.google.common.truth.Truth.assertThat +import foundation.e.apps.R import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.ApplicationDataManager +import foundation.e.apps.data.application.data.Home import foundation.e.apps.data.login.core.StoreType import foundation.e.apps.data.login.repository.AuthenticatorRepository import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException +import foundation.e.apps.data.enums.Source import foundation.e.apps.utils.SystemInfoProvider import io.mockk.every import io.mockk.mockk import io.mockk.mockkConstructor import io.mockk.mockkObject +import io.mockk.spyk import io.mockk.unmockkConstructor import io.mockk.unmockkObject +import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.test.runTest import org.mockito.kotlin.mock @@ -37,7 +47,7 @@ import org.robolectric.annotation.Config @Config(sdk = [Build.VERSION_CODES.N]) class PlayStoreRepositoryTest { - private val context = ApplicationProvider.getApplicationContext() + private val context = spyk(ApplicationProvider.getApplicationContext()) private val gPlayHttpClient = mockk() private val applicationDataManager = mockk(relaxed = true) private val playStoreSearchHelper = mockk() @@ -48,15 +58,28 @@ class PlayStoreRepositoryTest { mockkObject(SystemInfoProvider) every { SystemInfoProvider.getSystemProperty("ro.boot.qemu") } returns "0" mockkConstructor(AppDetailsHelper::class) + mockkConstructor(WebTopChartsHelper::class) every { anyConstructed().using(gPlayHttpClient) } answers { this.invocation.self as AppDetailsHelper } + + every { anyConstructed().using(gPlayHttpClient) } answers { + this.invocation.self as WebTopChartsHelper + } + + every { context.getString(R.string.topselling_free_apps) } returns "Top Free Apps" + every { context.getString(R.string.topselling_free_games) } returns "Top Free Games" + every { context.getString(R.string.topgrossing_apps) } returns "Top Grossing Apps" + every { context.getString(R.string.topgrossing_games) } returns "Top Grossing Games" + every { context.getString(R.string.movers_shakers_apps) } returns "Top Trending Apps" + every { context.getString(R.string.movers_shakers_games) } returns "Top Trending Games" } @After fun tearDown() { unmockkObject(SystemInfoProvider) unmockkConstructor(AppDetailsHelper::class) + unmockkConstructor(WebTopChartsHelper::class) } @Test @@ -65,13 +88,7 @@ class PlayStoreRepositoryTest { val app = App(packageName = "pkg.test", versionCode = 2) val authenticatorRepository = mock() - repository = PlayStoreRepository( - context = context, - gPlayHttpClient = gPlayHttpClient, - authenticatorRepository = authenticatorRepository, - applicationDataManager = applicationDataManager, - playStoreSearchHelper = playStoreSearchHelper - ) + repository = createRepository(authenticatorRepository) whenever(authenticatorRepository.getGPlayAuthOrThrow()).thenReturn(authData) whenever(authenticatorRepository.getValidatedAuthData()).thenReturn(ResultSupreme.Success(authData)) @@ -93,17 +110,12 @@ class PlayStoreRepositoryTest { val refreshedApp = App(packageName = "pkg.test", versionCode = 4) val authenticatorRepository = mock() - repository = PlayStoreRepository( - context = context, - gPlayHttpClient = gPlayHttpClient, - authenticatorRepository = authenticatorRepository, - applicationDataManager = applicationDataManager, - playStoreSearchHelper = playStoreSearchHelper - ) + repository = createRepository(authenticatorRepository) whenever(authenticatorRepository.getGPlayAuthOrThrow()).thenReturn(authData) whenever(authenticatorRepository.fetchAuthObjects(listOf(StoreType.PLAY_STORE))) .thenReturn(emptyList()) + every { anyConstructed().getAppByPackageName("pkg.test") } returns staleApp andThen refreshedApp @@ -120,13 +132,7 @@ class PlayStoreRepositoryTest { val authData = AuthData(email = "user@gmail.com") val authenticatorRepository = mock() - repository = PlayStoreRepository( - context = context, - gPlayHttpClient = gPlayHttpClient, - authenticatorRepository = authenticatorRepository, - applicationDataManager = applicationDataManager, - playStoreSearchHelper = playStoreSearchHelper - ) + repository = createRepository(authenticatorRepository) whenever(authenticatorRepository.getGPlayAuthOrThrow()).thenReturn(authData) every { @@ -147,17 +153,12 @@ class PlayStoreRepositoryTest { val refreshedApps = listOf(App(packageName = "pkg.test", versionCode = 9)) val authenticatorRepository = mock() - repository = PlayStoreRepository( - context = context, - gPlayHttpClient = gPlayHttpClient, - authenticatorRepository = authenticatorRepository, - applicationDataManager = applicationDataManager, - playStoreSearchHelper = playStoreSearchHelper - ) + repository = createRepository(authenticatorRepository) whenever(authenticatorRepository.getGPlayAuthOrThrow()).thenReturn(authData) whenever(authenticatorRepository.fetchAuthObjects(listOf(StoreType.PLAY_STORE))) .thenReturn(emptyList()) + every { anyConstructed().getAppByPackageName(listOf("pkg.test")) } returns staleApps andThen refreshedApps @@ -179,15 +180,10 @@ class PlayStoreRepositoryTest { App(packageName = "valid.pkg", versionCode = 0), App(packageName = "valid.pkg", versionCode = 5) ) + val authenticatorRepository = mock() - repository = PlayStoreRepository( - context = context, - gPlayHttpClient = gPlayHttpClient, - authenticatorRepository = authenticatorRepository, - applicationDataManager = applicationDataManager, - playStoreSearchHelper = playStoreSearchHelper - ) + repository = createRepository(authenticatorRepository) whenever(authenticatorRepository.getGPlayAuthOrThrow()).thenReturn(authData) every { @@ -199,4 +195,75 @@ class PlayStoreRepositoryTest { assertThat(result).hasSize(1) assertThat(result.first().package_name).isEqualTo("valid.pkg") } + + @Test + fun `getHomeScreenData populates list and updates app metadata`() = runTest { + val authenticatorRepository = mock() + + repository = createRepository(authenticatorRepository) + + val app = buildGplayApp("com.example.app") + val cluster = mockk() + every { cluster.clusterAppList } returns listOf(app) + every { anyConstructed().getCluster(any(), any()) } returns cluster + + val inputList = mutableListOf() + val result = repository.getHomeScreenData(inputList) + + val appCount = result.sumOf { it.list.size } + + assertThat(result).isSameInstanceAs(inputList) + assertThat(result).hasSize(6) + assertThat(result.map { it.source }).containsExactly( + ApplicationRepository.APP_TYPE_ANY, + ApplicationRepository.APP_TYPE_ANY, + ApplicationRepository.APP_TYPE_ANY, + ApplicationRepository.APP_TYPE_ANY, + ApplicationRepository.APP_TYPE_ANY, + ApplicationRepository.APP_TYPE_ANY + ) + + assertThat(result.flatMap { it.list }.all { it.source == Source.PLAY_STORE }).isTrue() + + verify(exactly = appCount) { applicationDataManager.updateStatus(any()) } + coVerify(exactly = appCount) { applicationDataManager.updateFilterLevel(any()) } + } + + private fun createRepository(authenticatorRepository: AuthenticatorRepository): PlayStoreRepository { + return PlayStoreRepository( + context, + gPlayHttpClient, + authenticatorRepository, + applicationDataManager, + playStoreSearchHelper + ) + } + + private fun buildGplayApp(packageName: String): App { + val artwork = mockk() + every { artwork.url } returns "https://example.com/icon.png" + + val app = mockk(relaxed = true) + every { app.id } returns 1 + every { app.developerName } returns "Dev" + every { app.categoryName } returns "Category" + every { app.description } returns "Description" + every { app.iconArtwork } returns artwork + every { app.updatedOn } returns "2024-01-01" + every { app.versionCode } returns 1L + every { app.versionName } returns "1.0" + every { app.displayName } returns "App" + every { app.screenshots } returns mutableListOf(artwork) + every { app.packageName } returns packageName + every { app.labeledRating } returns "4.5" + every { app.offerType } returns 1 + every { app.shareUrl } returns "https://example.com" + every { app.size } returns 1000L + every { app.isFree } returns true + every { app.price } returns "0" + every { app.restriction } returns Restriction.NOT_RESTRICTED + every { app.contentRating } returns mockk(relaxed = true) + + return app + } } diff --git a/app/src/test/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCaseTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ae1cb31e37f27fcd25da96b49397a689d38a30ea --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/home/FetchHomeScreenDataUseCaseTest.kt @@ -0,0 +1,102 @@ +/* + * 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.domain.home + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.ResultSupreme +import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.application.data.Home +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.lang.IllegalStateException + +@OptIn(ExperimentalCoroutinesApi::class) +class FetchHomeScreenDataUseCaseTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private val applicationRepository = mockk() + + @Test + fun invoke_maps_result_to_domain() = runTest(mainCoroutineRule.testDispatcher) { + val app = Application(_id = "app-1", package_name = "pkg.one", name = "App One") + val home = Home(title = "Play", list = listOf(app), source = ApplicationRepository.APP_TYPE_ANY) + val liveData = MutableLiveData>>() + liveData.value = ResultSupreme.Success(listOf(home)) + + coEvery { applicationRepository.getHomeScreenData() } returns liveData + + val useCase = FetchHomeScreenDataUseCase(applicationRepository) + val result = useCase().asFlow().first() + + assertThat(result).isInstanceOf(HomeScreenResult.Success::class.java) + assertThat(result.data).hasSize(1) + assertThat(result.data.first().apps.first().id).isEqualTo("app-1") + assertThat(result.data.first().apps.first().packageName).isEqualTo("pkg.one") + assertThat(result.data.first().apps.first().name).isEqualTo("App One") + } + + @Test + fun invoke_maps_timeout_to_domain() = runTest(mainCoroutineRule.testDispatcher) { + val app = Application(_id = "app-2", package_name = "pkg.two", name = "App Two") + val home = Home(title = "Play", list = listOf(app), source = ApplicationRepository.APP_TYPE_ANY) + val liveData = MutableLiveData>>() + liveData.value = ResultSupreme.Timeout(listOf(home)) + + coEvery { applicationRepository.getHomeScreenData() } returns liveData + + val useCase = FetchHomeScreenDataUseCase(applicationRepository) + val result = useCase().asFlow().first() + + assertThat(result).isInstanceOf(HomeScreenResult.Timeout::class.java) + assertThat(result.data).hasSize(1) + assertThat(result.data.first().apps.first().id).isEqualTo("app-2") + } + + @Test + fun invoke_maps_error_to_domain() = runTest(mainCoroutineRule.testDispatcher) { + val liveData = MutableLiveData>>() + val exception = IllegalStateException("boom") + liveData.value = ResultSupreme.Error("failed", exception) + + coEvery { applicationRepository.getHomeScreenData() } returns liveData + + val useCase = FetchHomeScreenDataUseCase(applicationRepository) + val result = useCase().asFlow().first() + + assertThat(result).isInstanceOf(HomeScreenResult.Error::class.java) + val error = result as HomeScreenResult.Error + assertThat(error.data).isEmpty() + assertThat(error.message).isEqualTo("failed") + assertThat(error.exception).isEqualTo(exception) + } +} diff --git a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt index 13cc3ce506bf081296cdb748337d8afb77bbf1b2..0f4a24cdc83524eee4f5e775c71202e4bfc32d32 100644 --- a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt +++ b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryImplTest.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.mockito.Mock @@ -89,7 +88,6 @@ class SearchRepositoryImplTest { @Mock private lateinit var playStoreRepository: PlayStoreRepository - @Mock private lateinit var stores: Stores @Mock @@ -111,6 +109,12 @@ class SearchRepositoryImplTest { MockitoAnnotations.openMocks(this) formatterMocked = Mockito.mockStatic(Formatter::class.java) preferenceManagerModule = FakeAppLoungePreference(context, appLoungeDataStore) + stores = Stores( + playStoreRepository, + cleanApkAppsRepository, + cleanApkPWARepository, + preferenceManagerModule + ) applicationDataManager = ApplicationDataManager(appLoungePackageManager, pwaManager, visibilityFetcher) val appSourcesContainer = @@ -142,7 +146,6 @@ class SearchRepositoryImplTest { price = "" ) - @Ignore("Dependencies are not mockable") @Test fun `getSearchResult When all sources are selected`() = runTest { val appList = mutableListOf( @@ -171,7 +174,7 @@ class SearchRepositoryImplTest { val searchResult = Search(apps = appList, numberOfResults = 3, success = true) val packageNameSearchResponse = Response.success(searchResult) - val gplayPackageResult = Application("com.search.package") + val gplayPackageResult = Application(package_name = "com.search.package") preferenceManagerModule.isPWASelectedFake = true preferenceManagerModule.isOpenSourceelectedFake = true @@ -192,7 +195,7 @@ class SearchRepositoryImplTest { searchRepository.getOpenSourceSearchResults("com.search.package") val size = searchResultLiveData.data?.first?.size ?: -2 - assertEquals("getSearchResult", 8, size) + assertEquals("getSearchResult", 6, size) } private suspend fun setupMockingSearchApp( @@ -209,10 +212,10 @@ class SearchRepositoryImplTest { formatterMocked.`when` { Formatter.formatFileSize(any(), any()) }.thenReturn("15MB") if (willThrowException) { - Mockito.`when`(playStoreRepository.getAppDetails("com.search.package")) + Mockito.`when`(playStoreRepository.getAppDetailsWeb("com.search.package")) .thenThrow(RuntimeException()) } else { - Mockito.`when`(playStoreRepository.getAppDetails(eq("com.search.package"))) + Mockito.`when`(playStoreRepository.getAppDetailsWeb(eq("com.search.package"))) .thenReturn(gplayPackageResult) } @@ -233,10 +236,9 @@ class SearchRepositoryImplTest { .thenReturn(apps) } - @Ignore("Dependencies are not mockable") @Test fun `getSearchResult When getApplicationDetailsThrowsException`() = runTest { - val appList = mutableListOf( + mutableListOf( Application( _id = "111", status = Status.UNAVAILABLE, @@ -260,7 +262,7 @@ class SearchRepositoryImplTest { ) ) - val gplayPackageResult = Application("com.search.package") + val gplayPackageResult = Application(package_name = "com.search.package") val playStoreApps = listOf( Application(package_name = "a.b.c"), @@ -280,6 +282,6 @@ class SearchRepositoryImplTest { searchRepository.getOpenSourceSearchResults("com.search.package") val size = searchResultLiveData.data?.first?.size ?: -2 - assertEquals("getSearchResult", 4, size) + assertEquals("getSearchResult", 1, size) } } diff --git a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryTest.kt b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryTest.kt index e49eef840a2bdbd26a6feab59aa87e1d813904bc..4fcd7ee7c9ee88ea0d0ebaea49b811e7fdf4cbb5 100644 --- a/app/src/test/java/foundation/e/apps/fused/SearchRepositoryTest.kt +++ b/app/src/test/java/foundation/e/apps/fused/SearchRepositoryTest.kt @@ -22,7 +22,6 @@ import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.apps.AppsApi import foundation.e.apps.data.application.category.CategoryApi import foundation.e.apps.data.application.downloadInfo.DownloadInfoApi -import foundation.e.apps.data.application.home.HomeApi import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -34,9 +33,6 @@ import org.mockito.kotlin.any class SearchRepositoryTest { private lateinit var applicationRepository: ApplicationRepository - @Mock - private lateinit var homeApi: HomeApi - @Mock private lateinit var categoryApi: CategoryApi @@ -53,7 +49,7 @@ class SearchRepositoryTest { fun setup() { MockitoAnnotations.openMocks(this) applicationRepository = - ApplicationRepository(homeApi, categoryApi, appsApi, downloadInfoApi, stores) + ApplicationRepository(categoryApi, appsApi, downloadInfoApi, stores) } @Test diff --git a/app/src/test/java/foundation/e/apps/home/HomeViewModelTest.kt b/app/src/test/java/foundation/e/apps/home/HomeViewModelTest.kt index 7ba95dcac6c674564d09c86a28b9433cf16053a7..b3e87a7d40ffbf9595df3e42f2f1157fa84ffd3d 100644 --- a/app/src/test/java/foundation/e/apps/home/HomeViewModelTest.kt +++ b/app/src/test/java/foundation/e/apps/home/HomeViewModelTest.kt @@ -19,10 +19,10 @@ package foundation.e.apps.home import foundation.e.apps.data.Stores -import foundation.e.apps.data.application.ApplicationRepository -import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.application.data.Home import foundation.e.apps.data.enums.Status +import foundation.e.apps.domain.home.FetchHomeScreenDataUseCase +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.home.HomeSection import foundation.e.apps.ui.home.HomeViewModel import org.junit.Assert.assertFalse import org.junit.Before @@ -33,7 +33,7 @@ import org.mockito.MockitoAnnotations class HomeViewModelTest { @Mock - private lateinit var applicationRepository: ApplicationRepository + private lateinit var fetchHomeScreenDataUseCase: FetchHomeScreenDataUseCase @Mock private lateinit var stores: Stores @@ -43,18 +43,18 @@ class HomeViewModelTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - homeViewModel = HomeViewModel(applicationRepository, stores) + homeViewModel = HomeViewModel(fetchHomeScreenDataUseCase, stores) } @Test fun `test hasAnyChange when app list sizes are not same`() { - val oldAppList = mutableListOf(Application("123"), Application("124"), Application("125")) - val newAppList = mutableListOf(Application("123"), Application("124")) + val oldAppList = mutableListOf(ApplicationDomain(id = "123"), ApplicationDomain(id = "124"), ApplicationDomain(id = "125")) + val newAppList = mutableListOf(ApplicationDomain(id = "123"), ApplicationDomain(id = "124")) val oldHomeData = - listOf(Home("Top Free Apps", oldAppList, id = "123"), Home("Top Free Games", oldAppList, id = "124")) + listOf(HomeSection("Top Free Apps", oldAppList, id = "123"), HomeSection("Top Free Games", oldAppList, id = "124")) val newHomeData = - listOf(Home("Top Free Apps", newAppList, id = "123"), Home("Top Free Games", newAppList, id = "124")) + listOf(HomeSection("Top Free Apps", newAppList, id = "123"), HomeSection("Top Free Games", newAppList, id = "124")) homeViewModel.currentHomes = oldHomeData @@ -64,13 +64,13 @@ class HomeViewModelTest { @Test fun `test hasAnyChange when contents are same`() { - val oldAppList = mutableListOf(Application("123"), Application("124"), Application("125")) - val newAppList = mutableListOf(Application("123"), Application("124"), Application("125")) + val oldAppList = mutableListOf(ApplicationDomain(id = "123"), ApplicationDomain(id = "124"), ApplicationDomain(id = "125")) + val newAppList = mutableListOf(ApplicationDomain(id = "123"), ApplicationDomain(id = "124"), ApplicationDomain(id = "125")) val oldHomeData = - listOf(Home("Top Free Apps", oldAppList, id = "123"), Home("Top Free Games", oldAppList, id = "124")) + listOf(HomeSection("Top Free Apps", oldAppList, id = "123"), HomeSection("Top Free Games", oldAppList, id = "124")) val newHomeData = - listOf(Home("Top Free Apps", newAppList, id = "123"), Home("Top Free Games", newAppList, id = "124")) + listOf(HomeSection("Top Free Apps", newAppList, id = "123"), HomeSection("Top Free Games", newAppList, id = "124")) homeViewModel.currentHomes = oldHomeData @@ -81,20 +81,20 @@ class HomeViewModelTest { @Test fun `test hasAnyChange when contents are not same`() { val oldAppList = mutableListOf( - Application("123"), - Application("124"), - Application("125") + ApplicationDomain(id = "123"), + ApplicationDomain(id = "124"), + ApplicationDomain(id = "125") ) val newAppList = mutableListOf( - Application("123"), - Application("124", status = Status.INSTALLED), - Application("125") + ApplicationDomain(id = "123"), + ApplicationDomain(id = "124", status = Status.INSTALLED), + ApplicationDomain(id = "125") ) val oldHomeData = - listOf(Home("Top Free Apps", oldAppList, id = "123"), Home("Top Free Games", oldAppList, id = "124")) + listOf(HomeSection("Top Free Apps", oldAppList, id = "123"), HomeSection("Top Free Games", oldAppList, id = "124")) val newHomeData = - listOf(Home("Top Free Apps", newAppList, id = "123"), Home("Top Free Games", newAppList, id = "124")) + listOf(HomeSection("Top Free Apps", newAppList, id = "123"), HomeSection("Top Free Games", newAppList, id = "124")) homeViewModel.currentHomes = oldHomeData 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 c4d9997411859d4fd01313a8f3655a2f050777ef..d3d8d103828b732f86e0be27dbdadecbd3febb59 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 @@ -27,11 +27,11 @@ import foundation.e.apps.install.pkg.PwaManager import foundation.e.apps.util.MainCoroutineRule import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.async import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -62,7 +62,7 @@ class InstallStatusStreamTest { val stream = InstallStatusStream(appManagerWrapper, appLoungePackageManager, pwaManager) - val snapshot = stream.stream(this, packagePollIntervalMs = 100, pwaPollIntervalMs = 100).first() + val snapshot = stream.stream(backgroundScope, packagePollIntervalMs = 100, pwaPollIntervalMs = 100).first() assertEquals(listOf(download), snapshot.downloads) assertEquals(setOf("com.example.one"), snapshot.installedPackages) @@ -84,16 +84,18 @@ class InstallStatusStreamTest { every { pwaManager.getInstalledPwaUrls() } returns setOf("https://pwa.example") val stream = InstallStatusStream(appManagerWrapper, appLoungePackageManager, pwaManager) - val deferred = async { - stream.stream(this, packagePollIntervalMs = 50, pwaPollIntervalMs = 50) + val snapshots = mutableListOf() + val job = backgroundScope.launch { + stream.stream(backgroundScope, packagePollIntervalMs = 50, pwaPollIntervalMs = 50) .take(2) - .toList() + .toList(snapshots) } + runCurrent() advanceTimeBy(50) runCurrent() - val snapshots = deferred.await() + job.join() assertEquals(setOf("com.example.one"), snapshots[0].installedPackages) assertEquals(setOf("com.example.two"), snapshots[1].installedPackages) }