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 e5948aa0a0df5bff7f2ab27899d6bfbae68f97c5..e7a813bf647f7f4aaedc30520f40ed777da6b979 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 @@ -21,6 +21,9 @@ package foundation.e.apps.api.fused import android.content.Context import android.text.format.Formatter import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.map import com.aurora.gplayapi.Constants import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App @@ -205,15 +208,23 @@ class FusedAPIImpl @Inject constructor( * Fetches search results from cleanAPK and GPlay servers and returns them * @param query Query * @param authData [AuthData] - * @return A list of nullable [FusedApp] + * @return A livedata Pair of list of non-nullable [FusedApp] and + * a Boolean signifying if more search results are being loaded. + * Observe this livedata to display new apps as they are fetched from the network. */ - suspend fun getSearchResults(query: String, authData: AuthData): Pair, ResultStatus> { - val fusedResponse = ArrayList() - val packageSpecificResults = ArrayList() - - val status = runCodeBlockWithTimeout({ + fun getSearchResults( + query: String, + authData: AuthData + ): LiveData, Boolean>>> { + /* + * Returning livedata to improve performance, so that we do not have to wait forever + * for all results to be fetched from network before showing them. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + return liveData { + val packageSpecificResults = ArrayList() - try { + val status = runCodeBlockWithTimeout({ if (preferenceManagerModule.preferredApplicationType() == APP_TYPE_ANY) { try { /* @@ -236,40 +247,95 @@ class FusedAPIImpl @Inject constructor( packageSpecificResults.add(it.data!!) } } - } catch (_: Exception) {} + }) + + /* + * 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 + } + /* + * 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. + * + * 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 cleanApkResults = mutableListOf() when (preferenceManagerModule.preferredApplicationType()) { APP_TYPE_ANY -> { - fusedResponse.addAll(getCleanAPKSearchResults(query)) - fusedResponse.addAll(getGplaySearchResults(query, authData)) + 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 -> { - fusedResponse.addAll(getCleanAPKSearchResults(query)) + 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 -> { - fusedResponse.addAll( - getCleanAPKSearchResults( - query, - CleanAPKInterface.APP_SOURCE_ANY, - CleanAPKInterface.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))) } } - }) - - /* - * 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. - * - * 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. - */ - val filteredResults = fusedResponse.distinctBy { it.package_name } - .filter { packageSpecificResults.isEmpty() || it.package_name != query } - - return Pair(packageSpecificResults + filteredResults, status) + } } /* @@ -889,10 +955,35 @@ class FusedAPIImpl @Inject constructor( return list } - private suspend fun getGplaySearchResults(query: String, authData: AuthData): 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 + ): LiveData, Boolean>> { val searchResults = gPlayAPIRepository.getSearchResults(query, authData) - return searchResults.map { app -> - app.transformToFusedApp() + return searchResults.map { + Pair( + it.first.map { app -> app.transformToFusedApp() }, + it.second + ) + } } 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 96e478cb2833faedc5b6531770e7ab350b9e46d4..5a6717d5d276b53fda49db400ef6dcfbbf832122 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 @@ -18,6 +18,7 @@ package foundation.e.apps.api.fused +import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -111,7 +112,7 @@ class FusedAPIRepository @Inject constructor( return fusedAPIImpl.fetchAuthData(email, aasToken) } - suspend fun getSearchResults(query: String, authData: AuthData): Pair, ResultStatus> { + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>>> { return fusedAPIImpl.getSearchResults(query, authData) } 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 254b28a80b56cb1a3427ed047c5d9f873d737a71..904b8cc49d10a9292431d85a45e6f7fe1b2886da 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 @@ -19,6 +19,8 @@ package foundation.e.apps.api.gplay import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -98,29 +100,41 @@ class GPlayAPIImpl @Inject constructor( return searchData.filter { it.suggestedQuery.isNotBlank() } } - suspend fun getSearchResults(query: String, authData: AuthData): List { - val searchData = mutableListOf() - withContext(Dispatchers.IO) { - val searchHelper = SearchHelper(authData).using(gPlayHttpClient) - val searchResult = searchHelper.searchResults(query) - searchData.addAll(searchResult.appList) + /** + * Sends livedata of list of apps being loaded from search and a boolean + * signifying if more data is to be loaded. + */ + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>> { + /* + * Send livedata to improve UI performance, so we don't have to wait for loading all results. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + return liveData { + withContext(Dispatchers.IO) { + /* + * Variable names and logic made same as that of Aurora store. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + val searchHelper = SearchHelper(authData).using(gPlayHttpClient) + val searchBundle = searchHelper.searchResults(query) + + emit(Pair(searchBundle.appList, true)) - // Fetch more results in case the given result is a promoted app - if (searchData.size == 1) { - val bundleSet: MutableSet = searchResult.subBundles + var nextSubBundleSet: MutableSet do { - val searchBundle = searchHelper.next(bundleSet) - if (searchBundle.appList.isNotEmpty()) { - searchData.addAll(searchBundle.appList) - } - bundleSet.apply { - clear() - addAll(searchBundle.subBundles) + nextSubBundleSet = searchBundle.subBundles + val newSearchBundle = searchHelper.next(nextSubBundleSet) + if (newSearchBundle.appList.isNotEmpty()) { + searchBundle.apply { + subBundles.clear() + subBundles.addAll(newSearchBundle.subBundles) + appList.addAll(newSearchBundle.appList) + emit(Pair(searchBundle.appList, nextSubBundleSet.isNotEmpty())) + } } - } while (bundleSet.isNotEmpty()) + } while (nextSubBundleSet.isNotEmpty()) } } - return searchData } suspend fun getDownloadInfo( 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 737f206a1dc1f75356c415d51e8338a5197ddc82..b6b098cbbe13798044da6d5e05e648eabecb8ec4 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 @@ -18,6 +18,7 @@ package foundation.e.apps.api.gplay +import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData @@ -48,7 +49,7 @@ class GPlayAPIRepository @Inject constructor( return gPlayAPIImpl.getSearchSuggestions(query, authData) } - suspend fun getSearchResults(query: String, authData: AuthData): List { + fun getSearchResults(query: String, authData: AuthData): LiveData, Boolean>> { return gPlayAPIImpl.getSearchResults(query, authData) } diff --git a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt index ab42f088712b3be612adb3728fea5046a6fc6d30..2c3c305e3084c3feda030aee7f74f8ee63709363 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchFragment.kt @@ -27,6 +27,7 @@ import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.LinearLayout import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible import androidx.cursoradapter.widget.CursorAdapter import androidx.cursoradapter.widget.SimpleCursorAdapter import androidx.fragment.app.activityViewModels @@ -82,6 +83,7 @@ class SearchFragment : private val appProgressViewModel: AppProgressViewModel by viewModels() private val SUGGESTION_KEY = "suggestion" + private var lastSearch = "" private var searchView: SearchView? = null private var shimmerLayout: ShimmerFrameLayout? = null @@ -162,7 +164,7 @@ class SearchFragment : mainActivityViewModel.downloadList.observe(viewLifecycleOwner) { list -> val searchList = - searchViewModel.searchResult.value?.first?.toMutableList() ?: emptyList() + searchViewModel.searchResult.value?.data?.first?.toMutableList() ?: emptyList() searchList.let { mainActivityViewModel.updateStatusOfFusedApps(searchList, list) } @@ -171,7 +173,7 @@ class SearchFragment : * Done in one line, so that on Ctrl+click on searchResult, * we can see that it is being updated here. */ - searchViewModel.searchResult.apply { value = Pair(searchList, value?.second) } + searchViewModel.searchResult.apply { value?.setData(Pair(searchList, value?.data?.second ?: false)) } } /* @@ -191,19 +193,32 @@ class SearchFragment : } searchViewModel.searchResult.observe(viewLifecycleOwner) { - if (it.first.isNullOrEmpty()) { + if (it.data?.first.isNullOrEmpty()) { noAppsFoundLayout?.visibility = View.VISIBLE } else { - listAdapter?.setData(it.first) + listAdapter?.setData(it.data!!.first) + binding.loadingProgressBar.isVisible = it.data!!.second stopLoadingUI() noAppsFoundLayout?.visibility = View.GONE } listAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - recyclerView!!.scrollToPosition(0) + searchView?.run { + /* + * Only scroll back to 0 position for a new search. + * + * If we are getting new results from livedata for the old search query, + * do not scroll to top as the user may be scrolling to see already + * populated results. + */ + if (lastSearch != query?.toString()) { + recyclerView?.scrollToPosition(0) + lastSearch = query.toString() + } + } } }) - if (searchText.isNotBlank() && it.second != ResultStatus.OK) { + if (searchText.isNotBlank() && !it.isSuccess()) { /* * If blank check is not performed then timeout dialog keeps * popping up whenever search tab is opened. @@ -215,6 +230,7 @@ class SearchFragment : override fun onTimeout() { if (!isTimeoutDialogDisplayed()) { + binding.loadingProgressBar.isVisible = false stopLoadingUI() displayTimeoutAlertDialog( timeoutFragment = this, @@ -235,7 +251,7 @@ class SearchFragment : override fun refreshData(authData: AuthData) { showLoadingUI() - searchViewModel.getSearchResults(searchText, authData) + searchViewModel.getSearchResults(searchText, authData, this) } private fun showLoadingUI() { diff --git a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt index 76a821549b8b40f0514abbc76eaf8b1fe3b88523..3ae636d47a5d84a5270367cfa5a500cde8471d04 100644 --- a/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/search/SearchViewModel.kt @@ -18,15 +18,16 @@ package foundation.e.apps.search +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData import dagger.hilt.android.lifecycle.HiltViewModel +import foundation.e.apps.api.ResultSupreme import foundation.e.apps.api.fused.FusedAPIRepository import foundation.e.apps.api.fused.data.FusedApp -import foundation.e.apps.utils.enums.ResultStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,7 +38,7 @@ class SearchViewModel @Inject constructor( ) : ViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() - val searchResult: MutableLiveData, ResultStatus?>> = MutableLiveData() + val searchResult: MutableLiveData, Boolean>>> = MutableLiveData() fun getSearchSuggestions(query: String, authData: AuthData) { viewModelScope.launch(Dispatchers.IO) { @@ -45,9 +46,17 @@ class SearchViewModel @Inject constructor( } } - fun getSearchResults(query: String, authData: AuthData) { - viewModelScope.launch(Dispatchers.IO) { - searchResult.postValue(fusedAPIRepository.getSearchResults(query, authData)) + /* + * Observe data from Fused API and publish the result in searchResult. + * This allows us to show apps as they are being fetched from the network, + * without having to wait for all of the apps. + * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 + */ + fun getSearchResults(query: String, authData: AuthData, lifecycleOwner: LifecycleOwner) { + viewModelScope.launch(Dispatchers.Main) { + fusedAPIRepository.getSearchResults(query, authData).observe(lifecycleOwner) { + searchResult.postValue(it) + } } } } diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 30699e08013ce98ea47f0605c4475d57a7625ecf..a4e3fbf2304579f6f51b032b907ff4bbf48b210f 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -50,7 +50,8 @@ + \ No newline at end of file