diff --git a/app/build.gradle b/app/build.gradle index e748d335f6d87f5968cfb0925ee5fc92733b4074..39cb415c192aed95b54f67d22ea315414b464f69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -135,8 +135,7 @@ dependencies { // Optional -- mockito-kotlin testImplementation "org.mockito.kotlin:mockito-kotlin:3.2.0" testImplementation 'org.mockito:mockito-inline:2.13.0' - - + testImplementation "androidx.arch.core:core-testing:2.1.0" // Coil and PhotoView implementation "io.coil-kt:coil:1.4.0" diff --git a/app/src/main/java/foundation/e/apps/api/DownloadManager.kt b/app/src/main/java/foundation/e/apps/api/DownloadManager.kt index c9a29d489ea5b88d531c82d95e90cc19dc8a46b4..850c0acb142b9869ba312f049271fd0fe3f6359a 100644 --- a/app/src/main/java/foundation/e/apps/api/DownloadManager.kt +++ b/app/src/main/java/foundation/e/apps/api/DownloadManager.kt @@ -125,7 +125,6 @@ class DownloadManager @Inject constructor( } catch (e: Exception) { Timber.e(e) } - } private fun tickerFlow(downloadId: Long, period: Duration, initialDelay: Duration = Duration.ZERO) = flow { diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt index be19a77d88371883a89e85dd098a63f8c47b5294..0736a4af3874679a0cab560fa3707a670cdceea4 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt @@ -144,23 +144,28 @@ class FusedAPIImpl @Inject constructor( * To prevent waiting so long and fail early, use withTimeout{}. */ withTimeout(timeoutDurationInMillis) { - if (applicationType != APP_TYPE_ANY) { - val response = if (applicationType == APP_TYPE_OPEN) { - cleanAPKRepository.getHomeScreenData( - CleanAPKInterface.APP_TYPE_ANY, - CleanAPKInterface.APP_SOURCE_FOSS - ).body() - } else { - cleanAPKRepository.getHomeScreenData( - CleanAPKInterface.APP_TYPE_PWA, - CleanAPKInterface.APP_SOURCE_ANY - ).body() + if (preferenceManagerModule.isGplaySelected()) { + list.addAll(fetchGPlayHome(authData)) + } + + if (preferenceManagerModule.isOpenSourceSelected()) { + val response = cleanAPKRepository.getHomeScreenData( + CleanAPKInterface.APP_TYPE_ANY, + CleanAPKInterface.APP_SOURCE_FOSS + ).body() + response?.home?.let { + list.addAll(generateCleanAPKHome(it, APP_TYPE_OPEN)) } + } + + if (preferenceManagerModule.isPWASelected()) { + val response = cleanAPKRepository.getHomeScreenData( + CleanAPKInterface.APP_TYPE_PWA, + CleanAPKInterface.APP_SOURCE_ANY + ).body() response?.home?.let { - list.addAll(generateCleanAPKHome(it, applicationType)) + list.addAll(generateCleanAPKHome(it, APP_TYPE_PWA)) } - } else { - list.addAll(fetchGPlayHome(authData)) } } } catch (e: TimeoutCancellationException) { @@ -194,18 +199,10 @@ class FusedAPIImpl @Inject constructor( var apiStatus: ResultStatus = ResultStatus.OK var applicationCategoryType = preferredApplicationType - if (preferredApplicationType != APP_TYPE_ANY) { - handleCleanApkCategories(preferredApplicationType, categoriesList, type).run { - if (this != ResultStatus.OK) { - apiStatus = this - } - } - } else { - handleAllSourcesCategories(categoriesList, type, authData).run { - if (first != ResultStatus.OK) { - apiStatus = first - applicationCategoryType = second - } + handleAllSourcesCategories(categoriesList, type, authData).run { + if (first != ResultStatus.OK) { + apiStatus = first + applicationCategoryType = second } } categoriesList.sortBy { item -> item.title.lowercase() } @@ -231,55 +228,172 @@ class FusedAPIImpl @Inject constructor( */ return liveData { val packageSpecificResults = ArrayList() - var gplayPackageResult: FusedApp? = null - var cleanapkPackageResult: FusedApp? = null - - val status = runCodeBlockWithTimeout({ - if (preferenceManagerModule.preferredApplicationType() == APP_TYPE_ANY) { - try { - /* - * Surrounding with try-catch because if query is not a package name, - * then GPlay throws an error. - */ - getApplicationDetails(query, query, authData, Origin.GPLAY).let { - if (it.second == ResultStatus.OK) { - gplayPackageResult = it.first - } - } - } catch (e: Exception) { - Timber.e(e) - } + fetchPackageSpecificResult(authData, query, packageSpecificResults)?.let { + if (it.data?.second == true) { // if there are no data to load + emit(it) + return@liveData } - getCleanapkSearchResult(query).let { - /* Cleanapk always returns something, it is never null. - * If nothing is found, it returns a blank FusedApp() object. - * Blank result to be filtered out. - */ - if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { - cleanapkPackageResult = it.data!! - } + } + + val searchResult = mutableListOf() + val cleanApkResults = mutableListOf() + + if (preferenceManagerModule.isOpenSourceSelected()) { + fetchOpenSourceSearchResult( + this@FusedAPIImpl, + cleanApkResults, + query, + searchResult, + packageSpecificResults + )?.let { emit(it) } + } + + if (preferenceManagerModule.isGplaySelected()) { + emitSource( + fetchGplaySearchResults( + query, + authData, + searchResult, + packageSpecificResults + ) + ) + } + + if (preferenceManagerModule.isPWASelected()) { + fetchPWASearchResult( + this@FusedAPIImpl, + query, + searchResult, + packageSpecificResults + )?.let { emit(it) } + } + } + } + + private suspend fun fetchPWASearchResult( + fusedAPIImpl: FusedAPIImpl, + query: String, + searchResult: MutableList, + packageSpecificResults: ArrayList + ): ResultSupreme, Boolean>>? { + val pwaApps: MutableList = mutableListOf() + val status = fusedAPIImpl.runCodeBlockWithTimeout({ + getCleanAPKSearchResults( + query, + CleanAPKInterface.APP_SOURCE_ANY, + CleanAPKInterface.APP_TYPE_PWA + ).apply { + if (this.isNotEmpty()) { + pwaApps.addAll(this) } - }) + } + }) - /* - * Currently only show open source package result if exists in both fdroid and gplay. - * This is temporary. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5783 - */ - cleanapkPackageResult?.let { packageSpecificResults.add(it) } ?: run { - gplayPackageResult?.let { packageSpecificResults.add(it) } + if (pwaApps.isNotEmpty() || status != ResultStatus.OK) { + searchResult.addAll(pwaApps) + return ResultSupreme.create( + status, + Pair( + filterWithKeywordSearch( + searchResult, + packageSpecificResults, + query + ), + false + ) + ) + } + return null + } + + private fun fetchGplaySearchResults( + query: String, + authData: AuthData, + searchResult: MutableList, + packageSpecificResults: ArrayList + ): LiveData, Boolean>>> = + getGplaySearchResults(query, authData).map { + if (it.first.isNotEmpty()) { + searchResult.addAll(it.first) } + ResultSupreme.Success( + Pair( + filterWithKeywordSearch( + searchResult, + packageSpecificResults, + query + ), + it.second + ) + ) + } - /* - * If there was a timeout, return it and don't try to fetch anything else. - * Also send true in the pair to signal more results being loaded. - */ - if (status != ResultStatus.OK) { - emit(ResultSupreme.create(status, Pair(packageSpecificResults, true))) - return@liveData + private suspend fun fetchOpenSourceSearchResult( + fusedAPIImpl: FusedAPIImpl, + cleanApkResults: MutableList, + query: String, + searchResult: MutableList, + packageSpecificResults: ArrayList + ): ResultSupreme, Boolean>>? { + val status = fusedAPIImpl.runCodeBlockWithTimeout({ + cleanApkResults.addAll(getCleanAPKSearchResults(query)) + }) + + if (cleanApkResults.isNotEmpty() || status != ResultStatus.OK) { + searchResult.addAll(cleanApkResults) + return ResultSupreme.create( + status, + Pair( + filterWithKeywordSearch( + searchResult, + packageSpecificResults, + query + ), + preferenceManagerModule.isGplaySelected() || preferenceManagerModule.isPWASelected() + ) + ) + } + return null + } + + private suspend fun fetchPackageSpecificResult( + authData: AuthData, + query: String, + packageSpecificResults: MutableList + ): ResultSupreme, Boolean>>? { + var gplayPackageResult: FusedApp? = null + var cleanapkPackageResult: FusedApp? = null + + val status = runCodeBlockWithTimeout({ + if (preferenceManagerModule.isGplaySelected()) { + gplayPackageResult = getGplayPackagResult(query, authData) } - /* + if (preferenceManagerModule.isOpenSourceSelected()) { + cleanapkPackageResult = getCleanApkPackageResult(query) + } + }) + + /* + * Currently only show open source package result if exists in both fdroid and gplay. + * This is temporary. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5783 + */ + cleanapkPackageResult?.let { packageSpecificResults.add(it) } ?: run { + gplayPackageResult?.let { packageSpecificResults.add(it) } + } + + /* + * If there was a timeout, return it and don't try to fetch anything else. + * Also send true in the pair to signal more results being loaded. + */ + if (status != ResultStatus.OK) { + return ResultSupreme.create(status, Pair(packageSpecificResults, true)) + } + return ResultSupreme.create(status, Pair(packageSpecificResults, false)) + } + + /* * The list packageSpecificResults may contain apps with duplicate package names. * Example, "org.telegram.messenger" will result in "Telegram" app from Play Store * and "Telegram FOSS" from F-droid. We show both of them at the top. @@ -287,81 +401,41 @@ class FusedAPIImpl @Inject constructor( * But for the other keyword related search results, we do not allow duplicate package names. * We also filter out apps which are already present in packageSpecificResults list. */ - fun filterWithKeywordSearch(list: List): List { - val filteredResults = list.distinctBy { it.package_name } - .filter { packageSpecificResults.isEmpty() || it.package_name != query } - return packageSpecificResults + filteredResults + private fun filterWithKeywordSearch( + list: List, + packageSpecificResults: List, + query: String + ): List { + val filteredResults = list.distinctBy { it.package_name } + .filter { packageSpecificResults.isEmpty() || it.package_name != query } + return packageSpecificResults + filteredResults + } + + private suspend fun getCleanApkPackageResult( + query: String, + ): FusedApp? { + getCleanapkSearchResult(query).let { + if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { + return it.data!! } + } + return null + } - val cleanApkResults = mutableListOf() - when (preferenceManagerModule.preferredApplicationType()) { - APP_TYPE_ANY -> { - val status = runCodeBlockWithTimeout({ - cleanApkResults.addAll(getCleanAPKSearchResults(query)) - }) - if (cleanApkResults.isNotEmpty() || status != ResultStatus.OK) { - /* - * If cleanapk results are empty, dont emit emit data as it may - * briefly show "No apps found..." - * If status is timeout, then do emit the value. - * Send true in the pair to signal more results (i.e from GPlay) being loaded. - */ - emit( - ResultSupreme.create( - status, - Pair(filterWithKeywordSearch(cleanApkResults), true) - ) - ) - } - emitSource( - getGplayAndCleanapkCombinedResults(query, authData, cleanApkResults).map { - /* - * We are assuming that there will be no timeout here. - * If there had to be any timeout, it would already have happened - * while fetching package specific results. - */ - ResultSupreme.Success( - Pair( - filterWithKeywordSearch(it.first), - it.second - ) - ) - } - ) - } - APP_TYPE_OPEN -> { - val status = runCodeBlockWithTimeout({ - cleanApkResults.addAll(getCleanAPKSearchResults(query)) - }) - /* - * Send false in pair to signal no more results to load, as only cleanapk - * results are fetched, we don't have to wait for GPlay results. - */ - emit( - ResultSupreme.create( - status, - Pair(filterWithKeywordSearch(cleanApkResults), false) - ) - ) - } - APP_TYPE_PWA -> { - val status = runCodeBlockWithTimeout({ - cleanApkResults.addAll( - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.APP_TYPE_PWA - ) - ) - }) - /* - * Send false in pair to signal no more results to load, as only cleanapk - * results are fetched for PWAs. - */ - emit(ResultSupreme.create(status, Pair(cleanApkResults, false))) + private suspend fun getGplayPackagResult( + query: String, + authData: AuthData, + ): FusedApp? { + try { + getApplicationDetails(query, query, authData, Origin.GPLAY).let { + if (it.second == ResultStatus.OK) { + return it.first } } + } catch (e: Exception) { + Timber.e(e) } + return null } /* @@ -408,8 +482,14 @@ class FusedAPIImpl @Inject constructor( moduleName: String, versionCode: Int, offerType: Int - ) : String? { - val list = gPlayAPIRepository.getOnDemandModule(packageName, moduleName, versionCode, offerType, authData) + ): String? { + val list = gPlayAPIRepository.getOnDemandModule( + packageName, + moduleName, + versionCode, + offerType, + authData + ) for (element in list) { if (element.name == "$moduleName.apk") { return element.url @@ -418,7 +498,6 @@ class FusedAPIImpl @Inject constructor( return null } - suspend fun updateFusedDownloadWithDownloadingInfo( authData: AuthData, origin: Origin, @@ -832,39 +911,69 @@ class FusedAPIImpl @Inject constructor( type: Category.Type, authData: AuthData ): Pair { - var data: Categories? = null var apiStatus = ResultStatus.OK var errorApplicationCategory = "" - /* - * Try within timeout limit for open source native apps categories. - */ + if (preferenceManagerModule.isOpenSourceSelected()) { + val openSourceCategoryResult = fetchOpenSourceCategories(type) + categoriesList.addAll(openSourceCategoryResult.second) + apiStatus = openSourceCategoryResult.first + errorApplicationCategory = openSourceCategoryResult.third + } + + if (preferenceManagerModule.isPWASelected()) { + val pwaCategoriesResult = fetchPWACategories(type) + categoriesList.addAll(pwaCategoriesResult.second) + apiStatus = pwaCategoriesResult.first + errorApplicationCategory = pwaCategoriesResult.third + } + + if (preferenceManagerModule.isGplaySelected()) { + val gplayCategoryResult = fetchGplayCategories( + type, + authData + ) + categoriesList.addAll(gplayCategoryResult.second) + apiStatus = gplayCategoryResult.first + errorApplicationCategory = gplayCategoryResult.third + } + + return Pair(apiStatus, errorApplicationCategory) + } + + private suspend fun FusedAPIImpl.fetchGplayCategories( + type: Category.Type, + authData: AuthData, + ): Triple, String> { + var errorApplicationCategory = "" + var apiStatus = ResultStatus.OK + val categoryList = mutableListOf() runCodeBlockWithTimeout({ - data = getOpenSourceCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, - type, - AppTag.OpenSource(context.getString(R.string.open_source)) - ) - ) + val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> + val category = app.transformToFusedCategory() + updateCategoryDrawable(category, app) + category } + categoryList.addAll(playResponse) }, { - errorApplicationCategory = APP_TYPE_OPEN + errorApplicationCategory = APP_TYPE_ANY apiStatus = ResultStatus.TIMEOUT }, { - errorApplicationCategory = APP_TYPE_OPEN + errorApplicationCategory = APP_TYPE_ANY apiStatus = ResultStatus.UNKNOWN }) + return Triple(apiStatus, categoryList, errorApplicationCategory) + } - /* - * Try within timeout limit to get PWA categories - */ + private suspend fun FusedAPIImpl.fetchPWACategories( + type: Category.Type, + ): Triple, String> { + var errorApplicationCategory = "" + var apiStatus: ResultStatus = ResultStatus.OK + val fusedCategoriesList = mutableListOf() runCodeBlockWithTimeout({ - data = getPWAsCategories() - data?.let { - categoriesList.addAll( + getPWAsCategories()?.let { + fusedCategoriesList.addAll( getFusedCategoryBasedOnCategoryType( it, type, AppTag.PWA(context.getString(R.string.pwa)) ) @@ -877,26 +986,33 @@ class FusedAPIImpl @Inject constructor( errorApplicationCategory = APP_TYPE_PWA apiStatus = ResultStatus.UNKNOWN }) + return Triple(apiStatus, fusedCategoriesList, errorApplicationCategory) + } - /* - * Try within timeout limit to get native app categories from Play Store - */ + private suspend fun FusedAPIImpl.fetchOpenSourceCategories( + type: Category.Type, + ): Triple, String> { + var errorApplicationCategory = "" + var apiStatus: ResultStatus = ResultStatus.OK + val fusedCategoryList = mutableListOf() runCodeBlockWithTimeout({ - val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> - val category = app.transformToFusedCategory() - updateCategoryDrawable(category, app) - category + getOpenSourceCategories()?.let { + fusedCategoryList.addAll( + getFusedCategoryBasedOnCategoryType( + it, + type, + AppTag.OpenSource(context.getString(R.string.open_source)) + ) + ) } - categoriesList.addAll(playResponse) }, { - errorApplicationCategory = APP_TYPE_ANY + errorApplicationCategory = APP_TYPE_OPEN apiStatus = ResultStatus.TIMEOUT }, { - errorApplicationCategory = APP_TYPE_ANY + errorApplicationCategory = APP_TYPE_OPEN apiStatus = ResultStatus.UNKNOWN }) - - return Pair(apiStatus, errorApplicationCategory) + return Triple(apiStatus, fusedCategoryList, errorApplicationCategory) } /** @@ -1077,24 +1193,6 @@ class FusedAPIImpl @Inject constructor( return list } - /* - * Function to return a livedata with value from cleanapk and Google Play store combined. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 - */ - private fun getGplayAndCleanapkCombinedResults( - query: String, - authData: AuthData, - cleanApkResults: List - ): LiveData, Boolean>> { - val localList = ArrayList(cleanApkResults) - return getGplaySearchResults(query, authData).map { pair -> - Pair( - localList.apply { addAll(pair.first) }.distinctBy { it.package_name }, - pair.second - ) - } - } - private fun getGplaySearchResults( query: String, authData: AuthData @@ -1112,9 +1210,9 @@ class FusedAPIImpl @Inject constructor( * Home screen-related internal functions */ - private suspend fun generateCleanAPKHome(home: Home, prefType: String): List { + private suspend fun generateCleanAPKHome(home: Home, appType: String): List { val list = mutableListOf() - val headings = if (prefType == APP_TYPE_OPEN) { + val headings = if (appType == APP_TYPE_OPEN) { mapOf( "top_updated_apps" to context.getString(R.string.top_updated_apps), "top_updated_games" to context.getString(R.string.top_updated_games), @@ -1203,7 +1301,10 @@ class FusedAPIImpl @Inject constructor( } } } - return list + return list.map { + it.source = appType + it + } } private suspend fun fetchGPlayHome(authData: AuthData): List { diff --git a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt index 232af31fa0b2f174b7a5afc8d8f4e17b6d13d929..bf0eae07a1bdc42af1d1812c443478d1f0b8546d 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIRepository.kt @@ -111,7 +111,7 @@ class FusedAPIRepository @Inject constructor( moduleName: String, versionCode: Int, offerType: Int - ) : String? { + ): String? { return fusedAPIImpl.getOnDemandModule(authData, packageName, moduleName, versionCode, offerType) } diff --git a/app/src/main/java/foundation/e/apps/api/fused/data/FusedHome.kt b/app/src/main/java/foundation/e/apps/api/fused/data/FusedHome.kt index 8ae22ffae407d5afd657b57b4813a4ad5e32f16f..1cb8cf996b2872a72bab3016b8a4d80dac5903d2 100644 --- a/app/src/main/java/foundation/e/apps/api/fused/data/FusedHome.kt +++ b/app/src/main/java/foundation/e/apps/api/fused/data/FusedHome.kt @@ -20,5 +20,6 @@ package foundation.e.apps.api.fused.data data class FusedHome( val title: String = String(), - val list: List = emptyList() + val list: List = emptyList(), + var source: String = String() ) diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt index 6b40827c13ed7261f4e83d94dbe5051058a7dfa5..a6d925734e3886464993cfbd469967134f8ae4eb 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt @@ -164,7 +164,7 @@ class GPlayAPIImpl @Inject constructor( versionCode: Int, offerType: Int, authData: AuthData - ) : List { + ): List { val downloadData = mutableListOf() withContext(Dispatchers.IO) { val purchaseHelper = PurchaseHelper(authData).using(gPlayHttpClient) diff --git a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt index ca31e917000e3c50382e752eacff2393c20a816e..e0309bb85a97d66f42ed7598faa874735b07fb20 100644 --- a/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIRepository.kt @@ -60,7 +60,7 @@ class GPlayAPIRepository @Inject constructor( versionCode: Int, offerType: Int, authData: AuthData - ) : List { + ): List { return gPlayAPIImpl.getOnDemandModule(packageName, moduleName, versionCode, offerType, authData) } diff --git a/app/src/main/java/foundation/e/apps/home/model/HomeParentRVAdapter.kt b/app/src/main/java/foundation/e/apps/home/model/HomeParentRVAdapter.kt index ab44628ed4f7ddbfeecdf4fdcf0dfe5ed101f41f..6dd0bf0016060f4b52ecc20ea31547dd9cb2d964 100644 --- a/app/src/main/java/foundation/e/apps/home/model/HomeParentRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/home/model/HomeParentRVAdapter.kt @@ -19,6 +19,7 @@ package foundation.e.apps.home.model import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.LinearLayoutManager @@ -26,6 +27,8 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import foundation.e.apps.AppInfoFetchViewModel import foundation.e.apps.MainActivityViewModel +import foundation.e.apps.R +import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.FusedAPIInterface import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.api.fused.data.FusedHome @@ -70,6 +73,20 @@ class HomeParentRVAdapter( homeChildRVAdapter.setData(fusedHome.list) holder.binding.titleTV.text = fusedHome.title + + when (fusedHome.source) { + FusedAPIImpl.APP_TYPE_OPEN -> { + holder.binding.categoryTag.visibility = View.VISIBLE + holder.binding.categoryTag.text = holder.binding.root.context.getString(R.string.open_source) + } + FusedAPIImpl.APP_TYPE_PWA -> { + holder.binding.categoryTag.visibility = View.VISIBLE + holder.binding.categoryTag.text = holder.binding.root.context.getString(R.string.pwa) + } + else -> { + holder.binding.categoryTag.visibility = View.GONE + } + } holder.binding.childRV.apply { recycledViewPool.setMaxRecycledViews(0, 0) adapter = homeChildRVAdapter diff --git a/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt b/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt index 7b7b569de174c3e23911bc83b38d4ee08a1f2717..07d9281616b36f17f6fa3795da34785a814575fe 100644 --- a/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt +++ b/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt @@ -28,6 +28,7 @@ import android.widget.Toast import androidx.fragment.app.viewModels import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController +import androidx.preference.CheckBoxPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.work.ExistingPeriodicWorkPolicy @@ -69,9 +70,9 @@ class SettingsFragment : PreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_preferences, rootKey) // Show applications preferences - val showAllApplications = findPreference("showAllApplications") - val showFOSSApplications = findPreference("showFOSSApplications") - val showPWAApplications = findPreference("showPWAApplications") + val showAllApplications = findPreference("showAllApplications") + val showFOSSApplications = findPreference("showFOSSApplications") + val showPWAApplications = findPreference("showPWAApplications") val updateCheckInterval = preferenceManager.findPreference(getString(R.string.update_check_intervals)) @@ -87,27 +88,6 @@ class SettingsFragment : PreferenceFragmentCompat() { true } - showAllApplications?.setOnPreferenceChangeListener { _, _ -> - showFOSSApplications?.isChecked = false - showPWAApplications?.isChecked = false - backToMainActivity() - true - } - - showFOSSApplications?.setOnPreferenceChangeListener { _, _ -> - showAllApplications?.isChecked = false - showPWAApplications?.isChecked = false - backToMainActivity() - true - } - - showPWAApplications?.setOnPreferenceChangeListener { _, _ -> - showFOSSApplications?.isChecked = false - showAllApplications?.isChecked = false - backToMainActivity() - true - } - val versionInfo = findPreference("versionInfo") versionInfo?.apply { summary = BuildConfig.VERSION_NAME diff --git a/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt b/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt index 5fbf54dc0989186443c3f5523fff08df72d1bb91..cc3fab1c609878ed4d91d39d08f6b730e2157bce 100644 --- a/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt +++ b/app/src/main/java/foundation/e/apps/utils/modules/PreferenceManagerModule.kt @@ -21,10 +21,12 @@ package foundation.e.apps.utils.modules import android.content.Context import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext +import foundation.e.apps.OpenForTesting import javax.inject.Inject import javax.inject.Singleton @Singleton +@OpenForTesting class PreferenceManagerModule @Inject constructor( @ApplicationContext private val context: Context ) { @@ -42,6 +44,10 @@ class PreferenceManagerModule @Inject constructor( } } + fun isOpenSourceSelected() = preferenceManager.getBoolean("showFOSSApplications", false) + fun isPWASelected() = preferenceManager.getBoolean("showPWAApplications", false) + fun isGplaySelected() = preferenceManager.getBoolean("showAllApplications", false) + fun autoUpdatePreferred(): Boolean { return preferenceManager.getBoolean("updateInstallAuto", false) } diff --git a/app/src/main/res/layout/home_parent_list_item.xml b/app/src/main/res/layout/home_parent_list_item.xml index 31645cec66201be879d77d131d215ae62224cc46..5ecf01ecc9cea49f2b363753e5f1d717d8631556 100644 --- a/app/src/main/res/layout/home_parent_list_item.xml +++ b/app/src/main/res/layout/home_parent_list_item.xml @@ -19,18 +19,46 @@ - + android:layout_marginEnd="5dp"> + + + + + + - - - diff --git a/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt b/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..b0f389eb085239fa8e9f73b6fb151e778394bc3b --- /dev/null +++ b/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 ECORP + * + * 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 + +import android.content.Context +import foundation.e.apps.utils.modules.PreferenceManagerModule + +class FakePreferenceModule(context: Context) : PreferenceManagerModule(context) { + var isPWASelectedFake = false + var isOpenSourceelectedFake = false + var isGplaySelectedFake = false + + override fun isPWASelected(): Boolean { + return isPWASelectedFake + } + + override fun isOpenSourceSelected(): Boolean { + return isOpenSourceelectedFake + } + + override fun isGplaySelected(): Boolean { + return isGplaySelectedFake + } + + override fun preferredApplicationType(): String { + return when { + isOpenSourceelectedFake -> "open" + isPWASelectedFake -> "pwa" + else -> "any" + } + } +} diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt index ace193cfd5b74f3658ddb23b4720de1227bec951..474bf05a230d4aab2920e443ec300550ff59739b 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt @@ -18,34 +18,59 @@ package foundation.e.apps import android.content.Context +import android.text.format.Formatter +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import com.aurora.gplayapi.Constants import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.Category +import foundation.e.apps.api.ResultSupreme +import foundation.e.apps.api.cleanapk.CleanAPKInterface import foundation.e.apps.api.cleanapk.CleanAPKRepository +import foundation.e.apps.api.cleanapk.data.categories.Categories +import foundation.e.apps.api.cleanapk.data.search.Search import foundation.e.apps.api.fused.FusedAPIImpl import foundation.e.apps.api.fused.data.FusedApp import foundation.e.apps.api.fused.data.FusedHome import foundation.e.apps.api.gplay.GPlayAPIRepository import foundation.e.apps.manager.pkg.PkgManagerModule +import foundation.e.apps.util.MainCoroutineRule import foundation.e.apps.utils.enums.FilterLevel import foundation.e.apps.utils.enums.Origin +import foundation.e.apps.utils.enums.ResultStatus import foundation.e.apps.utils.enums.Status import foundation.e.apps.utils.modules.PWAManagerModule -import foundation.e.apps.utils.modules.PreferenceManagerModule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any import org.mockito.kotlin.eq +import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class FusedApiImplTest { + + // Run tasks synchronously + @Rule + @JvmField + val instantExecutorRule = InstantTaskExecutorRule() + + // Sets the main coroutines dispatcher to a TestCoroutineScope for unit testing. + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + private lateinit var fusedAPIImpl: FusedAPIImpl @Mock @@ -63,12 +88,12 @@ class FusedApiImplTest { @Mock private lateinit var gPlayAPIRepository: GPlayAPIRepository - @Mock - private lateinit var preferenceManagerModule: PreferenceManagerModule + private lateinit var preferenceManagerModule: FakePreferenceModule @Before fun setup() { MockitoAnnotations.openMocks(this) + preferenceManagerModule = FakePreferenceModule(context) fusedAPIImpl = FusedAPIImpl( cleanApkRepository, gPlayAPIRepository, @@ -559,4 +584,219 @@ class FusedApiImplTest { val filterLevel = fusedAPIImpl.getAppFilterLevel(fusedApp, authData) assertEquals("getAppFilterLevel", FilterLevel.UI, filterLevel) } + + @Test + fun `getCategory when only pwa is selected`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val categories = + Categories(listOf("app one", "app two", "app three"), listOf("game 1", "game 2"), true) + val response = Response.success(categories) + preferenceManagerModule.isPWASelectedFake = true + preferenceManagerModule.isOpenSourceelectedFake = false + preferenceManagerModule.isGplaySelectedFake = false + + Mockito.`when`( + cleanApkRepository.getCategoriesList( + eq(CleanAPKInterface.APP_TYPE_PWA), + eq(CleanAPKInterface.APP_SOURCE_ANY) + ) + ).thenReturn(response) + + Mockito.`when`(context.getString(eq(R.string.pwa))).thenReturn("PWA") + + val categoryListResponse = + fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, authData) + assertEquals("getCategory", 3, categoryListResponse.first.size) + } + + @Test + fun `getCategory when only open source is selected`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val categories = + Categories(listOf("app one", "app two", "app three"), listOf("game 1", "game 2"), true) + val response = Response.success(categories) + + preferenceManagerModule.isPWASelectedFake = false + preferenceManagerModule.isOpenSourceelectedFake = true + preferenceManagerModule.isGplaySelectedFake = false + + Mockito.`when`( + cleanApkRepository.getCategoriesList( + eq(CleanAPKInterface.APP_TYPE_ANY), + eq(CleanAPKInterface.APP_SOURCE_FOSS) + ) + ).thenReturn(response) + Mockito.`when`(context.getString(eq(R.string.open_source))).thenReturn("Open source") + + val categoryListResponse = + fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, authData) + assertEquals("getCategory", 3, categoryListResponse.first.size) + } + + @Test + fun `getCategory when gplay source is selected`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val categories = listOf(Category(), Category(), Category(), Category()) + + preferenceManagerModule.isPWASelectedFake = false + preferenceManagerModule.isOpenSourceelectedFake = false + preferenceManagerModule.isGplaySelectedFake = true + + Mockito.`when`( + gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, authData) + ).thenReturn(categories) + + val categoryListResponse = + fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, authData) + assertEquals("getCategory", 4, categoryListResponse.first.size) + } + + @Test + fun `getCategory when gplay source is selected return error`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val categories = listOf(Category(), Category(), Category(), Category()) + + preferenceManagerModule.isPWASelectedFake = false + preferenceManagerModule.isOpenSourceelectedFake = false + preferenceManagerModule.isGplaySelectedFake = true + + Mockito.`when`( + gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, authData) + ).thenThrow(RuntimeException()) + + val categoryListResponse = + fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, authData) + assertEquals("getCategory", 0, categoryListResponse.first.size) + assertEquals("getCategory", ResultStatus.UNKNOWN, categoryListResponse.third) + } + + @Test + fun `getCategory when All source is selected`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val gplayCategories = listOf(Category(), Category(), Category(), Category()) + val openSourcecategories = + Categories( + listOf("app one", "app two", "app three", "app four"), + listOf("game 1", "game 2"), + true + ) + val openSourceResponse = Response.success(openSourcecategories) + val pwaCategories = + Categories(listOf("app one", "app two", "app three"), listOf("game 1", "game 2"), true) + val pwaResponse = Response.success(pwaCategories) + + Mockito.`when`( + cleanApkRepository.getCategoriesList( + eq(CleanAPKInterface.APP_TYPE_ANY), + eq(CleanAPKInterface.APP_SOURCE_FOSS) + ) + ).thenReturn(openSourceResponse) + + Mockito.`when`( + cleanApkRepository.getCategoriesList( + eq(CleanAPKInterface.APP_TYPE_PWA), + eq(CleanAPKInterface.APP_SOURCE_ANY) + ) + ).thenReturn(pwaResponse) + + Mockito.`when`( + gPlayAPIRepository.getCategoriesList(Category.Type.APPLICATION, authData) + ).thenReturn(gplayCategories) + + Mockito.`when`(context.getString(eq(R.string.open_source))).thenReturn("Open source") + Mockito.`when`(context.getString(eq(R.string.pwa))).thenReturn("pwa") + + preferenceManagerModule.isPWASelectedFake = true + preferenceManagerModule.isOpenSourceelectedFake = true + preferenceManagerModule.isGplaySelectedFake = true + + val categoryListResponse = + fusedAPIImpl.getCategoriesList(Category.Type.APPLICATION, authData) + assertEquals("getCategory", 11, categoryListResponse.first.size) + } + + @Test + fun `getSearchResult When all sources are selected`() = runTest { + val authData = AuthData("e@e.email", "AtadyMsIAtadyM") + val appList = mutableListOf( + FusedApp( + _id = "111", + status = Status.UNAVAILABLE, + name = "Demo One", + package_name = "foundation.e.demoone", + latest_version_code = 123 + ), + FusedApp( + _id = "112", + status = Status.UNAVAILABLE, + name = "Demo Two", + package_name = "foundation.e.demotwo", + latest_version_code = 123 + ), + FusedApp( + _id = "113", + status = Status.UNAVAILABLE, + name = "Demo Three", + package_name = "foundation.e.demothree", + latest_version_code = 123 + ) + ) + val searchResult = Search(apps = appList, numberOfResults = 3, success = true) + val packageNameSearchResponse = Response.success(searchResult) + val packageResult = App("com.search.package") + + preferenceManagerModule.isPWASelectedFake = true + preferenceManagerModule.isOpenSourceelectedFake = true + preferenceManagerModule.isGplaySelectedFake = true + val gplayLivedata = + MutableLiveData(Pair(listOf(App("a.b.c"), App("c.d.e"), App("d.e.f")), false)) + + setupMockingSearchApp(packageNameSearchResponse, authData, packageResult, gplayLivedata) + + val searchResultLiveData = fusedAPIImpl.getSearchResults("com.search.package", authData) + var size = -1 + val observer = Observer, Boolean>>> { + size = it.data?.first?.size ?: -2 + println("search result: $size") + } + searchResultLiveData.observeForever(observer) + delay(3000) + searchResultLiveData.removeObserver(observer) + assertEquals("getSearchResult", 1, size) + } + + private suspend fun setupMockingSearchApp( + packageNameSearchResponse: Response?, + authData: AuthData, + packageResult: App, + gplayLivedata: MutableLiveData, Boolean>> + ) { + Mockito.`when`(pwaManagerModule.getPwaStatus(any())).thenReturn(Status.UNAVAILABLE) + Mockito.`when`(pkgManagerModule.getPackageStatus(any(), any())) + .thenReturn(Status.UNAVAILABLE) + Mockito.`when`( + cleanApkRepository.searchApps( + keyword = "com.search.package", + by = "package_name" + ) + ).thenReturn(packageNameSearchResponse) + val formatterMocked = Mockito.mockStatic(Formatter::class.java) + formatterMocked.`when` { Formatter.formatFileSize(any(), any()) }.thenReturn("15MB") + + Mockito.`when`(gPlayAPIRepository.getAppDetails(eq("com.search.package"), eq(authData))) + .thenReturn(packageResult) + + Mockito.`when`(cleanApkRepository.searchApps(keyword = "com.search.package")) + .thenReturn(packageNameSearchResponse) + + Mockito.`when`( + cleanApkRepository.searchApps( + keyword = "com.search.package", + type = CleanAPKInterface.APP_TYPE_PWA, + source = CleanAPKInterface.APP_SOURCE_ANY + ) + ).thenReturn(packageNameSearchResponse) + Mockito.`when`(gPlayAPIRepository.getSearchResults(eq("com.search.package"), eq(authData))) + .thenReturn(gplayLivedata) + } } diff --git a/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt new file mode 100644 index 0000000000000000000000000000000000000000..a28ada765d8778e4a37defd14f82670324642b1d --- /dev/null +++ b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2022 ECORP + * + * 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.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * Gets the value of a [LiveData] or waits for it to have one, with a timeout. + * + * Use this extension from host-side (JVM) tests. It's recommended to use it alongside + * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. + */ +fun LiveData.getOrAwaitValue( + time: Long = 5, + timeUnit: TimeUnit = TimeUnit.SECONDS, + afterObserve: () -> Unit = {} +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(o: T?) { + data = o + latch.countDown() + this@getOrAwaitValue.removeObserver(this) + } + } + this.observeForever(observer) + + afterObserve.invoke() + + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + this.removeObserver(observer) + throw TimeoutException("LiveData value was never set.") + } + + @Suppress("UNCHECKED_CAST") + return data as T +} + +/** + * Observes a [LiveData] until the `block` is done executing. + */ +suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { + val observer = Observer { } + try { + observeForever(observer) + block() + } finally { + removeObserver(observer) + } +} diff --git a/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt b/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt new file mode 100644 index 0000000000000000000000000000000000000000..ae4ddf877e6750f8f480423e3b65bc26497eef72 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2022 ECORP + * + * 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.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * Sets the main coroutines dispatcher to a [StandardTestDispatcher] for unit testing. A + * [StandardTestDispatcher] provides control over the execution of coroutines. + * + * Declare it as a JUnit Rule: + * + * ``` + * @get:Rule + * var mainCoroutineRule = MainCoroutineRule() + * ``` + * + * Then, use `runTest` to execute your tests. + */ +@ExperimentalCoroutinesApi +class MainCoroutineRule : TestWatcher() { + + val testDispatcher = StandardTestDispatcher() + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + } +}