From ac729e495c35a0b1bbbadff57b415910b5801570 Mon Sep 17 00:00:00 2001 From: Hasib Prince Date: Thu, 25 Aug 2022 18:31:33 +0600 Subject: [PATCH 1/6] multiple source handling for Home/category page --- .../e/apps/api/fused/FusedAPIImpl.kt | 155 +++++++++--------- .../e/apps/settings/SettingsFragment.kt | 19 ++- .../utils/modules/PreferenceManagerModule.kt | 4 + app/src/main/res/xml/settings_preferences.xml | 8 +- 4 files changed, 99 insertions(+), 87 deletions(-) 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 26893c3d0..3b93289a2 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 @@ -143,23 +143,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, applicationType)) } + } + + if (preferenceManagerModule.isPWASelected()) { + val response = cleanAPKRepository.getHomeScreenData( + CleanAPKInterface.APP_TYPE_PWA, + CleanAPKInterface.APP_SOURCE_ANY + ).body() response?.home?.let { list.addAll(generateCleanAPKHome(it, applicationType)) } - } else { - list.addAll(fetchGPlayHome(authData)) } } } catch (e: TimeoutCancellationException) { @@ -190,18 +195,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() } @@ -803,65 +800,73 @@ class FusedAPIImpl @Inject constructor( var apiStatus = ResultStatus.OK var errorApplicationCategory = "" - /* + if (preferenceManagerModule.isOpenSourceSelected()) { + /* * Try within timeout limit for open source native apps categories. */ - runCodeBlockWithTimeout({ - data = getOpenSourceCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, - type, - AppTag.OpenSource(context.getString(R.string.open_source)) + runCodeBlockWithTimeout({ + data = getOpenSourceCategories() + data?.let { + categoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + it, + type, + AppTag.OpenSource(context.getString(R.string.open_source)) + ) ) - ) - } - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.UNKNOWN - }) + } + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.UNKNOWN + }) + } - /* + if (preferenceManagerModule.isPWASelected()) { + + /* * Try within timeout limit to get PWA categories */ - runCodeBlockWithTimeout({ - data = getPWAsCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, type, AppTag.PWA(context.getString(R.string.pwa)) + runCodeBlockWithTimeout({ + data = getPWAsCategories() + data?.let { + categoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + it, type, AppTag.PWA(context.getString(R.string.pwa)) + ) ) - ) - } - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.UNKNOWN - }) + } + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.UNKNOWN + }) + } - /* + if (preferenceManagerModule.isGplaySelected()) { + + /* * Try within timeout limit to get native app categories from Play Store */ - runCodeBlockWithTimeout({ - val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> - val category = app.transformToFusedCategory() - updateCategoryDrawable(category, app) - category - } - categoriesList.addAll(playResponse) - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.UNKNOWN - }) + runCodeBlockWithTimeout({ + val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> + val category = app.transformToFusedCategory() + updateCategoryDrawable(category, app) + category + } + categoriesList.addAll(playResponse) + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.UNKNOWN + }) + } return Pair(apiStatus, errorApplicationCategory) } @@ -905,7 +910,7 @@ class FusedAPIImpl @Inject constructor( private fun getCategoryIconName(category: FusedCategory): String { var categoryTitle = if (category.tag.getOperationalTag() - .contentEquals(AppTag.GPlay().getOperationalTag()) + .contentEquals(AppTag.GPlay().getOperationalTag()) ) category.id else category.title if (categoryTitle.contains(CATEGORY_TITLE_REPLACEABLE_CONJUNCTION)) { 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 7b7b569de..637d9ebb8 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)) @@ -88,22 +89,22 @@ class SettingsFragment : PreferenceFragmentCompat() { } showAllApplications?.setOnPreferenceChangeListener { _, _ -> - showFOSSApplications?.isChecked = false - showPWAApplications?.isChecked = false +// showFOSSApplications?.isChecked = false +// showPWAApplications?.isChecked = false backToMainActivity() true } showFOSSApplications?.setOnPreferenceChangeListener { _, _ -> - showAllApplications?.isChecked = false - showPWAApplications?.isChecked = false +// showAllApplications?.isChecked = false +// showPWAApplications?.isChecked = false backToMainActivity() true } showPWAApplications?.setOnPreferenceChangeListener { _, _ -> - showFOSSApplications?.isChecked = false - showAllApplications?.isChecked = false +// showFOSSApplications?.isChecked = false +// showAllApplications?.isChecked = false backToMainActivity() true } 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 5fbf54dc0..5bb6907ed 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 @@ -42,6 +42,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/xml/settings_preferences.xml b/app/src/main/res/xml/settings_preferences.xml index 9bb9435d9..28d6968b5 100644 --- a/app/src/main/res/xml/settings_preferences.xml +++ b/app/src/main/res/xml/settings_preferences.xml @@ -53,16 +53,18 @@ - - - -- GitLab From 9b2d274fd22e4aff23295da089b08a3c37cdab39 Mon Sep 17 00:00:00 2001 From: Hasib Prince Date: Sun, 28 Aug 2022 12:30:37 +0600 Subject: [PATCH 2/6] multiple app source for search page --- .../e/apps/api/fused/FusedAPIImpl.kt | 142 +++++++++--------- 1 file changed, 74 insertions(+), 68 deletions(-) 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 3b93289a2..44b9da8b2 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 @@ -78,6 +78,7 @@ class FusedAPIImpl @Inject constructor( companion object { private const val CATEGORY_TITLE_REPLACEABLE_CONJUNCTION = "&" + /* * Removing "private" access specifier to allow access in * MainActivityViewModel.timeoutAlertDialog @@ -189,7 +190,10 @@ class FusedAPIImpl @Inject constructor( * * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ - suspend fun getCategoriesList(type: Category.Type, authData: AuthData): Triple, String, ResultStatus> { + suspend fun getCategoriesList( + type: Category.Type, + authData: AuthData + ): Triple, String, ResultStatus> { val categoriesList = mutableListOf() val preferredApplicationType = preferenceManagerModule.preferredApplicationType() var apiStatus: ResultStatus = ResultStatus.OK @@ -228,26 +232,22 @@ class FusedAPIImpl @Inject constructor( var cleanapkPackageResult: FusedApp? = null val status = runCodeBlockWithTimeout({ - if (preferenceManagerModule.preferredApplicationType() == APP_TYPE_ANY) { + if (preferenceManagerModule.isGplaySelected()) { 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 (_: Exception) {} + } catch (_: Exception) { + } } - 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!! + + if (preferenceManagerModule.isOpenSourceSelected()) { + getCleanapkSearchResult(query).let { + if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { + cleanapkPackageResult = it.data!! + } } } }) @@ -261,6 +261,7 @@ class FusedAPIImpl @Inject constructor( 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. @@ -284,67 +285,66 @@ class FusedAPIImpl @Inject constructor( return packageSpecificResults + filteredResults } + val searchResult = mutableListOf() 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)) - }) + if (preferenceManagerModule.isOpenSourceSelected()) { + val status = runCodeBlockWithTimeout({ + cleanApkResults.addAll(getCleanAPKSearchResults(query)) + }) + if (cleanApkResults.isNotEmpty() || status != ResultStatus.OK) { /* - * 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. + * 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. */ + searchResult.addAll(cleanApkResults) emit( ResultSupreme.create( status, - Pair(filterWithKeywordSearch(cleanApkResults), false) + Pair( + filterWithKeywordSearch(searchResult), + preferenceManagerModule.isGplaySelected() || preferenceManagerModule.isPWASelected() + ) ) ) } - APP_TYPE_PWA -> { - val status = runCodeBlockWithTimeout({ - cleanApkResults.addAll( - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.APP_TYPE_PWA + } + + if (preferenceManagerModule.isGplaySelected()) { + emitSource(getGplaySearchResults(query, authData).map { + if (it.first.isNotEmpty()) { + searchResult.addAll(it.first) + } + ResultSupreme.Success(Pair(filterWithKeywordSearch(searchResult), it.second)) + }) + } + + if (preferenceManagerModule.isPWASelected()) { + val pwaApps: MutableList = mutableListOf() + val status = runCodeBlockWithTimeout({ + getCleanAPKSearchResults( + query, + CleanAPKInterface.APP_SOURCE_ANY, + CleanAPKInterface.APP_TYPE_PWA + ).apply { + if (this.isNotEmpty()) { + pwaApps.addAll(this) + } + } + }) + + if (pwaApps.isNotEmpty() || status != ResultStatus.OK) { + searchResult.addAll(pwaApps) + emit( + ResultSupreme.create( + status, + Pair( + filterWithKeywordSearch(searchResult), + false ) ) - }) - /* - * 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))) + ) } } } @@ -451,7 +451,8 @@ class FusedAPIImpl @Inject constructor( ): ResultSupreme { var streamBundle = StreamBundle() val status = runCodeBlockWithTimeout({ - streamBundle = gPlayAPIRepository.getNextStreamBundle(authData, homeUrl, currentStreamBundle) + streamBundle = + gPlayAPIRepository.getNextStreamBundle(authData, homeUrl, currentStreamBundle) }) return ResultSupreme.create(status, streamBundle) } @@ -463,7 +464,8 @@ class FusedAPIImpl @Inject constructor( ): ResultSupreme { var streamCluster = StreamCluster() val status = runCodeBlockWithTimeout({ - streamCluster = gPlayAPIRepository.getAdjustedFirstCluster(authData, streamBundle, pointer) + streamCluster = + gPlayAPIRepository.getAdjustedFirstCluster(authData, streamBundle, pointer) }) return ResultSupreme.create(status, streamCluster) } @@ -479,7 +481,10 @@ class FusedAPIImpl @Inject constructor( return ResultSupreme.create(status, streamCluster) } - suspend fun getPlayStoreApps(browseUrl: String, authData: AuthData): ResultSupreme> { + suspend fun getPlayStoreApps( + browseUrl: String, + authData: AuthData + ): ResultSupreme> { val list = mutableListOf() val status = runCodeBlockWithTimeout({ list.addAll( @@ -1330,7 +1335,8 @@ class FusedAPIImpl @Inject constructor( if (it.status == Status.INSTALLATION_ISSUE) { return@forEach } - val currentAppStatus = pkgManagerModule.getPackageStatus(it.package_name, it.latest_version_code) + val currentAppStatus = + pkgManagerModule.getPackageStatus(it.package_name, it.latest_version_code) if (it.status != currentAppStatus) { return true } -- GitLab From 51f769be0b93262b9c3dac71ba619b09fab4bc96 Mon Sep 17 00:00:00 2001 From: Hasib Prince Date: Mon, 29 Aug 2022 16:08:17 +0600 Subject: [PATCH 3/6] ux improvement --- .../e/apps/api/fused/FusedAPIImpl.kt | 9 ++-- .../e/apps/api/fused/data/FusedHome.kt | 3 +- .../e/apps/home/model/HomeParentRVAdapter.kt | 17 ++++++++ .../main/res/layout/home_parent_list_item.xml | 43 +++++++++++++++---- 4 files changed, 60 insertions(+), 12 deletions(-) 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 44b9da8b2..140573eec 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 @@ -154,7 +154,7 @@ class FusedAPIImpl @Inject constructor( CleanAPKInterface.APP_SOURCE_FOSS ).body() response?.home?.let { - list.addAll(generateCleanAPKHome(it, applicationType)) + list.addAll(generateCleanAPKHome(it, APP_TYPE_OPEN)) } } @@ -164,7 +164,7 @@ class FusedAPIImpl @Inject constructor( CleanAPKInterface.APP_SOURCE_ANY ).body() response?.home?.let { - list.addAll(generateCleanAPKHome(it, applicationType)) + list.addAll(generateCleanAPKHome(it, APP_TYPE_PWA)) } } } @@ -1180,7 +1180,10 @@ class FusedAPIImpl @Inject constructor( } } } - return list + return list.map { + it.source = prefType + it + } } private suspend fun fetchGPlayHome(authData: AuthData): List { 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 8ae22ffae..1cb8cf996 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/home/model/HomeParentRVAdapter.kt b/app/src/main/java/foundation/e/apps/home/model/HomeParentRVAdapter.kt index ab44628ed..6dd0bf001 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/res/layout/home_parent_list_item.xml b/app/src/main/res/layout/home_parent_list_item.xml index 31645cec6..0db84bb3d 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,45 @@ - + android:layout_marginEnd="5dp"> + + + + + + Date: Tue, 30 Aug 2022 17:58:14 +0600 Subject: [PATCH 4/6] refactoring of GplayApiImpl Settings page updated for multiple app source selection --- .../e/apps/api/fused/FusedAPIImpl.kt | 466 +++++++++++------- .../e/apps/settings/SettingsFragment.kt | 21 - 2 files changed, 285 insertions(+), 202 deletions(-) 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 d62e8e030..54b6cc40d 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 @@ -228,51 +228,172 @@ class FusedAPIImpl @Inject constructor( */ return liveData { val packageSpecificResults = ArrayList() - var gplayPackageResult: FusedApp? = null - var cleanapkPackageResult: FusedApp? = null + fetchPackageSpecificResult(authData, query, packageSpecificResults)?.let { + emit(it) + return@liveData + } - val status = runCodeBlockWithTimeout({ - if (preferenceManagerModule.isGplaySelected()) { - try { - getApplicationDetails(query, query, authData, Origin.GPLAY).let { - if (it.second == ResultStatus.OK) { - gplayPackageResult = it.first - } - } - } catch (e: Exception) { - Timber.e(e) - } - } + val searchResult = mutableListOf() + val cleanApkResults = mutableListOf() - if (preferenceManagerModule.isOpenSourceSelected()) { - getCleanapkSearchResult(query).let { - if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { - cleanapkPackageResult = it.data!! - } - } + 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 + ) + ) + } + 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 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 + 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()) { + getGplayPackagResult(query, authData, gplayPackageResult) } - /* + if (preferenceManagerModule.isOpenSourceSelected()) { + getCleanApkPackageResult(query, cleanapkPackageResult) + } + }) + + /* + * 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 null + } + + /* * 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. @@ -280,76 +401,42 @@ 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 - } - - val searchResult = mutableListOf() - val cleanApkResults = mutableListOf() - - if (preferenceManagerModule.isOpenSourceSelected()) { - 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. - */ - searchResult.addAll(cleanApkResults) - emit( - ResultSupreme.create( - status, - Pair( - filterWithKeywordSearch(searchResult), - preferenceManagerModule.isGplaySelected() || preferenceManagerModule.isPWASelected() - ) - ) - ) - } - } + 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 + } - if (preferenceManagerModule.isGplaySelected()) { - emitSource(getGplaySearchResults(query, authData).map { - if (it.first.isNotEmpty()) { - searchResult.addAll(it.first) - } - ResultSupreme.Success(Pair(filterWithKeywordSearch(searchResult), it.second)) - }) + private suspend fun getCleanApkPackageResult( + query: String, + cleanapkPackageResult: FusedApp? + ) { + var cleanapkPackageResultReference = cleanapkPackageResult + getCleanapkSearchResult(query).let { + if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { + cleanapkPackageResultReference = it.data!! } + } + } - if (preferenceManagerModule.isPWASelected()) { - val pwaApps: MutableList = mutableListOf() - val status = runCodeBlockWithTimeout({ - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.APP_TYPE_PWA - ).apply { - if (this.isNotEmpty()) { - pwaApps.addAll(this) - } - } - }) - - if (pwaApps.isNotEmpty() || status != ResultStatus.OK) { - searchResult.addAll(pwaApps) - emit( - ResultSupreme.create( - status, - Pair( - filterWithKeywordSearch(searchResult), - false - ) - ) - ) + private suspend fun getGplayPackagResult( + query: String, + authData: AuthData, + gplayPackageResult: FusedApp? + ) { + var gplayPackageResultReference = gplayPackageResult + try { + getApplicationDetails(query, query, authData, Origin.GPLAY).let { + if (it.second == ResultStatus.OK) { + gplayPackageResultReference = it.first } } + } catch (e: Exception) { + Timber.e(e) } } @@ -397,8 +484,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 @@ -821,81 +914,110 @@ class FusedAPIImpl @Inject constructor( type: Category.Type, authData: AuthData ): Pair { - var data: Categories? = null var apiStatus = ResultStatus.OK var errorApplicationCategory = "" if (preferenceManagerModule.isOpenSourceSelected()) { - /* - * Try within timeout limit for open source native apps categories. - */ - runCodeBlockWithTimeout({ - data = getOpenSourceCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, - type, - AppTag.OpenSource(context.getString(R.string.open_source)) - ) - ) - } - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_OPEN - apiStatus = ResultStatus.UNKNOWN - }) + val openSourceCategoryResult = fetchOpenSourceCategories(type) + categoriesList.addAll(openSourceCategoryResult.second) + apiStatus = openSourceCategoryResult.first + errorApplicationCategory = openSourceCategoryResult.third } if (preferenceManagerModule.isPWASelected()) { - - /* - * Try within timeout limit to get PWA categories - */ - runCodeBlockWithTimeout({ - data = getPWAsCategories() - data?.let { - categoriesList.addAll( - getFusedCategoryBasedOnCategoryType( - it, type, AppTag.PWA(context.getString(R.string.pwa)) - ) - ) - } - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_PWA - apiStatus = ResultStatus.UNKNOWN - }) + val pwaCategoriesResult = fetchPWACategories(type) + categoriesList.addAll(pwaCategoriesResult.second) + apiStatus = pwaCategoriesResult.first + errorApplicationCategory = pwaCategoriesResult.third } if (preferenceManagerModule.isGplaySelected()) { - - /* - * Try within timeout limit to get native app categories from Play Store - */ - runCodeBlockWithTimeout({ - val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> - val category = app.transformToFusedCategory() - updateCategoryDrawable(category, app) - category - } - categoriesList.addAll(playResponse) - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.TIMEOUT - }, { - errorApplicationCategory = APP_TYPE_ANY - apiStatus = ResultStatus.UNKNOWN - }) + 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({ + val playResponse = gPlayAPIRepository.getCategoriesList(type, authData).map { app -> + val category = app.transformToFusedCategory() + updateCategoryDrawable(category, app) + category + } + categoryList.addAll(playResponse) + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_ANY + apiStatus = ResultStatus.UNKNOWN + }) + return Triple(apiStatus, categoryList, errorApplicationCategory) + } + + private suspend fun FusedAPIImpl.fetchPWACategories( + type: Category.Type, + ): Triple, String> { + var errorApplicationCategory = "" + var apiStatus: ResultStatus = ResultStatus.OK + val fusedCategoriesList = mutableListOf() + runCodeBlockWithTimeout({ + getPWAsCategories()?.let { + fusedCategoriesList.addAll( + getFusedCategoryBasedOnCategoryType( + it, type, AppTag.PWA(context.getString(R.string.pwa)) + ) + ) + } + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_PWA + apiStatus = ResultStatus.UNKNOWN + }) + return Triple(apiStatus, fusedCategoriesList, errorApplicationCategory) + } + + private suspend fun FusedAPIImpl.fetchOpenSourceCategories( + type: Category.Type, + ): Triple, String> { + var errorApplicationCategory = "" + var apiStatus: ResultStatus = ResultStatus.OK + val fusedCategoryList = mutableListOf() + runCodeBlockWithTimeout({ + getOpenSourceCategories()?.let { + fusedCategoryList.addAll( + getFusedCategoryBasedOnCategoryType( + it, + type, + AppTag.OpenSource(context.getString(R.string.open_source)) + ) + ) + } + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.TIMEOUT + }, { + errorApplicationCategory = APP_TYPE_OPEN + apiStatus = ResultStatus.UNKNOWN + }) + return Triple(apiStatus, fusedCategoryList, errorApplicationCategory) + } + /** * Run a block of code with timeout. Returns status. * @@ -1074,24 +1196,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 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 637d9ebb8..07d928161 100644 --- a/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt +++ b/app/src/main/java/foundation/e/apps/settings/SettingsFragment.kt @@ -88,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 -- GitLab From 116b6a67e0ef18c759d99cca396696fee2a4d78e Mon Sep 17 00:00:00 2001 From: Hasib Prince Date: Thu, 1 Sep 2022 17:13:20 +0600 Subject: [PATCH 5/6] unit test added some refactoring for unit test --- app/build.gradle | 3 +- .../e/apps/api/fused/FusedAPIImpl.kt | 20 +- .../utils/modules/PreferenceManagerModule.kt | 2 + .../main/res/layout/home_parent_list_item.xml | 15 +- .../foundation/e/apps/FakePreferenceModule.kt | 47 ++++ .../foundation/e/apps/FusedApiImplTest.kt | 249 +++++++++++++++++- .../e/apps/util/LiveDataTestUtil.kt | 71 +++++ .../e/apps/util/MainCoroutineRule.kt | 55 ++++ 8 files changed, 439 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/foundation/e/apps/FakePreferenceModule.kt create mode 100644 app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt create mode 100644 app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt diff --git a/app/build.gradle b/app/build.gradle index e748d335f..39cb415c1 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/fused/FusedAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/fused/FusedAPIImpl.kt index 54b6cc40d..2d3c01fa4 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 @@ -365,11 +365,11 @@ class FusedAPIImpl @Inject constructor( val status = runCodeBlockWithTimeout({ if (preferenceManagerModule.isGplaySelected()) { - getGplayPackagResult(query, authData, gplayPackageResult) + gplayPackageResult = getGplayPackagResult(query, authData) } if (preferenceManagerModule.isOpenSourceSelected()) { - getCleanApkPackageResult(query, cleanapkPackageResult) + cleanapkPackageResult = getCleanApkPackageResult(query) } }) @@ -390,7 +390,7 @@ class FusedAPIImpl @Inject constructor( if (status != ResultStatus.OK) { return ResultSupreme.create(status, Pair(packageSpecificResults, true)) } - return null + return ResultSupreme.create(status, Pair(packageSpecificResults, false)) } /* @@ -413,31 +413,29 @@ class FusedAPIImpl @Inject constructor( private suspend fun getCleanApkPackageResult( query: String, - cleanapkPackageResult: FusedApp? - ) { - var cleanapkPackageResultReference = cleanapkPackageResult + ): FusedApp? { getCleanapkSearchResult(query).let { if (it.isSuccess() && it.data!!.package_name.isNotBlank()) { - cleanapkPackageResultReference = it.data!! + return it.data!! } } + return null } private suspend fun getGplayPackagResult( query: String, authData: AuthData, - gplayPackageResult: FusedApp? - ) { - var gplayPackageResultReference = gplayPackageResult + ): FusedApp? { try { getApplicationDetails(query, query, authData, Origin.GPLAY).let { if (it.second == ResultStatus.OK) { - gplayPackageResultReference = it.first + return it.first } } } catch (e: Exception) { Timber.e(e) } + return null } /* 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 5bb6907ed..cc3fab1c6 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 ) { 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 0db84bb3d..0578138a8 100644 --- a/app/src/main/res/layout/home_parent_list_item.xml +++ b/app/src/main/res/layout/home_parent_list_item.xml @@ -43,19 +43,20 @@ + tools:text="Open Source" /> 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 000000000..e16aad26a --- /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" + } + } +} \ No newline at end of file diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt index ace193cfd..09e348815 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,13 @@ 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 +585,221 @@ 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 000000000..493da9e49 --- /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) + } +} \ No newline at end of file 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 000000000..23bf45801 --- /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() + } +} \ No newline at end of file -- GitLab From 17914931c6dfdcb4baffbb2bb98b7a589328c915 Mon Sep 17 00:00:00 2001 From: Hasib Prince Date: Fri, 2 Sep 2022 12:22:21 +0600 Subject: [PATCH 6/6] Fixed lint issues --- .../foundation/e/apps/api/DownloadManager.kt | 1 - .../e/apps/api/fused/FusedAPIImpl.kt | 23 +++++++++---------- .../e/apps/api/fused/FusedAPIRepository.kt | 2 +- .../e/apps/api/gplay/GPlayAPIImpl.kt | 2 +- .../e/apps/api/gplay/GPlayAPIRepository.kt | 2 +- .../main/res/layout/home_parent_list_item.xml | 2 +- .../foundation/e/apps/FakePreferenceModule.kt | 2 +- .../foundation/e/apps/FusedApiImplTest.kt | 3 --- .../e/apps/util/LiveDataTestUtil.kt | 4 ++-- .../e/apps/util/MainCoroutineRule.kt | 2 +- 10 files changed, 19 insertions(+), 24 deletions(-) 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 c9a29d489..850c0acb1 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 2d3c01fa4..0736a4af3 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 @@ -229,8 +229,10 @@ class FusedAPIImpl @Inject constructor( return liveData { val packageSpecificResults = ArrayList() fetchPackageSpecificResult(authData, query, packageSpecificResults)?.let { - emit(it) - return@liveData + if (it.data?.second == true) { // if there are no data to load + emit(it) + return@liveData + } } val searchResult = mutableListOf() @@ -320,7 +322,8 @@ class FusedAPIImpl @Inject constructor( searchResult, packageSpecificResults, query - ), it.second + ), + it.second ) ) } @@ -349,7 +352,6 @@ class FusedAPIImpl @Inject constructor( preferenceManagerModule.isGplaySelected() || preferenceManagerModule.isPWASelected() ) ) - } return null } @@ -362,7 +364,6 @@ class FusedAPIImpl @Inject constructor( var gplayPackageResult: FusedApp? = null var cleanapkPackageResult: FusedApp? = null - val status = runCodeBlockWithTimeout({ if (preferenceManagerModule.isGplaySelected()) { gplayPackageResult = getGplayPackagResult(query, authData) @@ -382,7 +383,6 @@ class FusedAPIImpl @Inject constructor( 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. @@ -498,7 +498,6 @@ class FusedAPIImpl @Inject constructor( return null } - suspend fun updateFusedDownloadWithDownloadingInfo( authData: AuthData, origin: Origin, @@ -945,7 +944,7 @@ class FusedAPIImpl @Inject constructor( private suspend fun FusedAPIImpl.fetchGplayCategories( type: Category.Type, authData: AuthData, - ): Triple,String> { + ): Triple, String> { var errorApplicationCategory = "" var apiStatus = ResultStatus.OK val categoryList = mutableListOf() @@ -1055,7 +1054,7 @@ class FusedAPIImpl @Inject constructor( private fun getCategoryIconName(category: FusedCategory): String { var categoryTitle = if (category.tag.getOperationalTag() - .contentEquals(AppTag.GPlay().getOperationalTag()) + .contentEquals(AppTag.GPlay().getOperationalTag()) ) category.id else category.title if (categoryTitle.contains(CATEGORY_TITLE_REPLACEABLE_CONJUNCTION)) { @@ -1211,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), @@ -1303,7 +1302,7 @@ class FusedAPIImpl @Inject constructor( } } return list.map { - it.source = prefType + it.source = appType it } } 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 232af31fa..bf0eae07a 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/gplay/GPlayAPIImpl.kt b/app/src/main/java/foundation/e/apps/api/gplay/GPlayAPIImpl.kt index 6b40827c1..a6d925734 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 ca31e9170..e0309bb85 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/res/layout/home_parent_list_item.xml b/app/src/main/res/layout/home_parent_list_item.xml index 0578138a8..5ecf01ecc 100644 --- a/app/src/main/res/layout/home_parent_list_item.xml +++ b/app/src/main/res/layout/home_parent_list_item.xml @@ -46,7 +46,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" - android:layout_marginEnd="15dp" + android:layout_marginEnd="14dp" android:background="@drawable/bg_tag_rounded" android:paddingStart="6dp" android:paddingTop="2dp" diff --git a/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt b/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt index e16aad26a..b0f389eb0 100644 --- a/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt +++ b/app/src/test/java/foundation/e/apps/FakePreferenceModule.kt @@ -44,4 +44,4 @@ class FakePreferenceModule(context: Context) : PreferenceManagerModule(context) else -> "any" } } -} \ No newline at end of file +} diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt index 09e348815..474bf05a2 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt @@ -88,7 +88,6 @@ class FusedApiImplTest { @Mock private lateinit var gPlayAPIRepository: GPlayAPIRepository - private lateinit var preferenceManagerModule: FakePreferenceModule @Before @@ -754,13 +753,11 @@ class FusedApiImplTest { 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) diff --git a/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt index 493da9e49..a28ada765 100644 --- a/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt +++ b/app/src/test/java/foundation/e/apps/util/LiveDataTestUtil.kt @@ -60,7 +60,7 @@ fun LiveData.getOrAwaitValue( /** * Observes a [LiveData] until the `block` is done executing. */ -suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { +suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { val observer = Observer { } try { observeForever(observer) @@ -68,4 +68,4 @@ suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { } finally { removeObserver(observer) } -} \ No newline at end of file +} diff --git a/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt b/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt index 23bf45801..ae4ddf877 100644 --- a/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt +++ b/app/src/test/java/foundation/e/apps/util/MainCoroutineRule.kt @@ -52,4 +52,4 @@ class MainCoroutineRule : TestWatcher() { super.finished(description) Dispatchers.resetMain() } -} \ No newline at end of file +} -- GitLab