Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9bf99950 authored by Sayantan Roychowdhury's avatar Sayantan Roychowdhury
Browse files

Merge branch '5171-fix_limited_search_results' into 'main'

Issue 5171: Fix limited search results

See merge request !140
parents b4a3aeee a317ca85
Loading
Loading
Loading
Loading
Loading
+125 −34
Original line number Diff line number Diff line
@@ -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<List<FusedApp>, ResultStatus> {
        val fusedResponse = ArrayList<FusedApp>()
    fun getSearchResults(
        query: String,
        authData: AuthData
    ): LiveData<ResultSupreme<Pair<List<FusedApp>, 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<FusedApp>()

            val status = runCodeBlockWithTimeout({

            try {
                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<FusedApp>): List<FusedApp> {
                val filteredResults = list.distinctBy { it.package_name }
                    .filter { packageSpecificResults.isEmpty() || it.package_name != query }
                return packageSpecificResults + filteredResults
            }

            val cleanApkResults = mutableListOf<FusedApp>()
            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(
                    val status = runCodeBlockWithTimeout({
                        cleanApkResults.addAll(
                            getCleanAPKSearchResults(
                                query,
                                CleanAPKInterface.APP_SOURCE_ANY,
                                CleanAPKInterface.APP_TYPE_PWA
                            )
                        )
                }
            }
                    })

                    /*
         * 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.
                     * Send false in pair to signal no more results to load, as only cleanapk
                     * results are fetched for PWAs.
                     */
        val filteredResults = fusedResponse.distinctBy { it.package_name }
            .filter { packageSpecificResults.isEmpty() || it.package_name != query }

        return Pair(packageSpecificResults + filteredResults, status)
                    emit(ResultSupreme.create(status, Pair(cleanApkResults, false)))
                }
            }
        }
    }

    /*
@@ -889,10 +955,35 @@ class FusedAPIImpl @Inject constructor(
        return list
    }

    private suspend fun getGplaySearchResults(query: String, authData: AuthData): List<FusedApp> {
    /*
     * 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<FusedApp>
    ): LiveData<Pair<List<FusedApp>, Boolean>> {
        val localList = ArrayList<FusedApp>(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<Pair<List<FusedApp>, 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
            )

        }
    }

+2 −1
Original line number Diff line number Diff line
@@ -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<List<FusedApp>, ResultStatus> {
    fun getSearchResults(query: String, authData: AuthData): LiveData<ResultSupreme<Pair<List<FusedApp>, Boolean>>> {
        return fusedAPIImpl.getSearchResults(query, authData)
    }

+32 −18
Original line number Diff line number Diff line
@@ -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<App> {
        val searchData = mutableListOf<App>()
    /**
     * 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<Pair<List<App>, 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 searchResult = searchHelper.searchResults(query)
            searchData.addAll(searchResult.appList)
                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<SearchBundle.SubBundle> = searchResult.subBundles
                var nextSubBundleSet: MutableSet<SearchBundle.SubBundle>
                do {
                    val searchBundle = searchHelper.next(bundleSet)
                    if (searchBundle.appList.isNotEmpty()) {
                        searchData.addAll(searchBundle.appList)
                    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()))
                        }
                    bundleSet.apply {
                        clear()
                        addAll(searchBundle.subBundles)
                    }
                } while (bundleSet.isNotEmpty())
                } while (nextSubBundleSet.isNotEmpty())
            }
        }
        return searchData
    }

    suspend fun getDownloadInfo(
+2 −1
Original line number Diff line number Diff line
@@ -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<App> {
    fun getSearchResults(query: String, authData: AuthData): LiveData<Pair<List<App>, Boolean>> {
        return gPlayAPIImpl.getSearchResults(query, authData)
    }

+23 −7
Original line number Diff line number Diff line
@@ -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() {
Loading