diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt index 0aa269ab6eaf1ea1b6c084be525dcd12b1bedb0b..00dd50683738bdcc6da11490f52790cf822c3575 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedAPIRepository.kt @@ -21,6 +21,7 @@ package foundation.e.apps.data.fused import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.SearchBundle import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.Origin @@ -107,11 +108,18 @@ class FusedAPIRepository @Inject constructor(private val fusedAPIImpl: FusedApi) return fusedAPIImpl.getSearchSuggestions(query) } - fun getSearchResults( + suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): LiveData, Boolean>>> { - return fusedAPIImpl.getSearchResults(query, authData) + ): ResultSupreme, Boolean>> { + return fusedAPIImpl.getCleanApkSearchResults(query, authData) + } + + suspend fun getGplaySearchResults( + query: String, + nextPageSubBundle: Set? + ): GplaySearchResult { + return fusedAPIImpl.getGplaySearchResult(query, nextPageSubBundle) } suspend fun getAppsListBasedOnCategory( diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt index 2c0296373762bb121cff30bbbbaf445f6c40d7ac..7267f634b42767665972f8e00101e193e88bac9b 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedApi.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.SearchBundle import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.cleanapk.data.download.Download import foundation.e.apps.data.enums.FilterLevel @@ -17,6 +18,8 @@ import foundation.e.apps.data.fused.utils.CategoryType import foundation.e.apps.data.fusedDownload.models.FusedDownload import retrofit2.Response +typealias GplaySearchResult = ResultSupreme, Set>> + interface FusedApi { companion object { const val APP_TYPE_ANY = "any" @@ -55,14 +58,18 @@ interface FusedApi { * Fetches search results from cleanAPK and GPlay servers and returns them * @param query Query * @param authData [AuthData] - * @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. + * @return ResultSupreme which contains a Pair, Boolean> where List + * is the app list and [Boolean] indicates more data to load or not. */ - fun getSearchResults( + suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): LiveData, Boolean>>> + ): ResultSupreme, Boolean>> + + suspend fun getGplaySearchResult( + query: String, + nextPageSubBundle: Set? + ): GplaySearchResult suspend fun getSearchSuggestions(query: String): List diff --git a/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt index 510337d264aba619df6053aa39179ed3cb233630..16b3d2cefb7e6ac34f476ba3c510a6fd199aaf6b 100644 --- a/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/fused/FusedApiImpl.kt @@ -29,6 +29,7 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Category +import com.aurora.gplayapi.data.models.SearchBundle import com.aurora.gplayapi.data.models.StreamCluster import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.R @@ -62,7 +63,8 @@ import foundation.e.apps.data.fused.utils.CategoryType import foundation.e.apps.data.fused.utils.CategoryUtils import foundation.e.apps.data.fusedDownload.models.FusedDownload import foundation.e.apps.data.gplay.GplayStoreRepository -import foundation.e.apps.data.gplay.utils.runFlowWithTimeout +import foundation.e.apps.data.gplay.utils.GplayHttpRequestException +import foundation.e.apps.data.login.exceptions.GPlayException import foundation.e.apps.data.preference.PreferenceManagerModule import foundation.e.apps.install.pkg.PWAManagerModule import foundation.e.apps.install.pkg.PkgManagerModule @@ -71,16 +73,15 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withTimeout import retrofit2.Response import timber.log.Timber +import java.net.SocketTimeoutException import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton -typealias GplaySearchResultLiveData = LiveData, Boolean>>> typealias FusedHomeDeferred = Deferred>> @Singleton @@ -99,6 +100,8 @@ class FusedApiImpl @Inject constructor( private const val CATEGORY_TITLE_REPLACEABLE_CONJUNCTION = "&" private const val CATEGORY_OPEN_GAMES_ID = "game_open_games" private const val CATEGORY_OPEN_GAMES_TITLE = "Open games" + private const val ERROR_GPLAY_SEARCH = "Gplay search has failed!" + private const val ERROR_GPLAY_SOURCE_NOT_SELECTED = "Gplay apps are not selected!" } /** @@ -244,67 +247,54 @@ class FusedApiImpl @Inject constructor( * a Boolean signifying if more search results are being loaded. * Observe this livedata to display new apps as they are fetched from the network. */ - override fun getSearchResults( + override suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): LiveData, Boolean>>> { + ): ResultSupreme, 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() + val packageSpecificResults = ArrayList() + var finalSearchResult: ResultSupreme, Boolean>> = ResultSupreme.Error() - fetchPackageSpecificResult(authData, query, packageSpecificResults).let { - if (it.data?.second != true) { // if there are no data to load - emit(it) - return@liveData - } + fetchPackageSpecificResult(authData, query, packageSpecificResults).let { + if (it.data?.second != true) { // if there are no data to load + return it } + } - val searchResult = mutableListOf() - val cleanApkResults = mutableListOf() + val searchResult = mutableListOf() + val cleanApkResults = mutableListOf() - if (preferenceManagerModule.isOpenSourceSelected()) { - fetchOpenSourceSearchResult( - this@FusedApiImpl, - cleanApkResults, - query, - searchResult, - packageSpecificResults - ).let { emit(it) } - } - - if (preferenceManagerModule.isPWASelected()) { - fetchPWASearchResult( - this@FusedApiImpl, - query, - searchResult, - packageSpecificResults - ).let { emit(it) } - } + if (preferenceManagerModule.isOpenSourceSelected()) { + finalSearchResult = fetchOpenSourceSearchResult( + cleanApkResults, + query, + searchResult, + packageSpecificResults + ) + } - if (preferenceManagerModule.isGplaySelected()) { - emitSource( - fetchGplaySearchResults( - query, - searchResult, - packageSpecificResults - ) - ) - } + if (preferenceManagerModule.isPWASelected()) { + finalSearchResult = fetchPWASearchResult( + query, + searchResult, + packageSpecificResults + ) } + + return finalSearchResult } private suspend fun fetchPWASearchResult( - fusedAPIImpl: FusedApiImpl, query: String, searchResult: MutableList, packageSpecificResults: ArrayList ): ResultSupreme, Boolean>> { val pwaApps: MutableList = mutableListOf() - val status = fusedAPIImpl.runCodeWithTimeout({ + val status = runCodeWithTimeout({ val apps = cleanApkPWARepository.getSearchResult(query).body()?.apps apps?.apply { @@ -331,46 +321,13 @@ class FusedApiImpl @Inject constructor( ) } - private suspend fun fetchGplaySearchResults( - query: String, - searchResult: MutableList, - packageSpecificResults: ArrayList - ): GplaySearchResultLiveData { - return runFlowWithTimeout( - { - getGplaySearchResult(query) - }, { - it.second - }, { - Pair(listOf(), false) // empty data for timeout - } - ).map { - if (it.isSuccess()) { - searchResult.addAll(it.data!!.first) - ResultSupreme.Success( - Pair( - filterWithKeywordSearch( - searchResult, - packageSpecificResults, - query - ), - it.data!!.second - ) - ) - } else { - it - } - } - } - private suspend fun fetchOpenSourceSearchResult( - fusedAPIImpl: FusedApiImpl, cleanApkResults: MutableList, query: String, searchResult: MutableList, packageSpecificResults: ArrayList ): ResultSupreme, Boolean>> { - val status = fusedAPIImpl.runCodeWithTimeout({ + val status = runCodeWithTimeout({ cleanApkResults.addAll(getCleanAPKSearchResults(query)) }) @@ -1115,16 +1072,39 @@ class FusedApiImpl @Inject constructor( return list } - private suspend fun getGplaySearchResult( + override suspend fun getGplaySearchResult( query: String, - ): Flow, Boolean>> { - val searchResults = gplayRepository.getSearchResult(query) - return searchResults.map { - val fusedAppList = it.first.map { app -> replaceWithFDroid(app) } - Pair( - fusedAppList, - it.second - ) + nextPageSubBundle: Set? + ): GplaySearchResult { + try { + val searchResults = + gplayRepository.getSearchResult(query, nextPageSubBundle?.toMutableSet()) + + if (!preferenceManagerModule.isGplaySelected()) { + return ResultSupreme.Error(ERROR_GPLAY_SOURCE_NOT_SELECTED) + } + + val fusedAppList = + searchResults.first.map { app -> replaceWithFDroid(app) }.toMutableList() + + if (searchResults.second.isNotEmpty()) { + fusedAppList.add(FusedApp(isPlaceHolder = true)) + } + + return ResultSupreme.Success(Pair(fusedAppList.toList(), searchResults.second.toSet())) + } catch (e: GplayHttpRequestException) { + val message = ( + e.localizedMessage?.ifBlank { ERROR_GPLAY_SEARCH } + ?: ERROR_GPLAY_SEARCH + ) + "Status: ${e.status}" + + val exception = GPlayException(e.status == 408, message) + return ResultSupreme.Error(message, exception) + } catch (e: Exception) { + val exception = + GPlayException(e is SocketTimeoutException, e.localizedMessage) + + return ResultSupreme.Error(e.localizedMessage ?: "", exception) } } @@ -1431,7 +1411,9 @@ class FusedApiImpl @Inject constructor( var nextPageUrl = "" val status = runCodeWithTimeout({ - val streamCluster = gplayRepository.getAppsByCategory(category, pageUrl) as StreamCluster + val streamCluster = + gplayRepository.getAppsByCategory(category, pageUrl) as StreamCluster + val filteredAppList = filterRestrictedGPlayApps(authData, streamCluster.clusterAppList) filteredAppList.data?.let { fusedAppList = it.toMutableList() diff --git a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepository.kt b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepository.kt index c1f3d3e1e2fc1402ab3c491842ed4a7b53cf7ef3..5ad24dc4386efbdc4dab22e973804017b6389166 100644 --- a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepository.kt @@ -22,12 +22,12 @@ import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.File +import com.aurora.gplayapi.data.models.SearchBundle import foundation.e.apps.data.BaseStoreRepository import foundation.e.apps.data.fused.utils.CategoryType -import kotlinx.coroutines.flow.Flow interface GplayStoreRepository : BaseStoreRepository { - suspend fun getSearchResult(query: String): Flow, Boolean>> + suspend fun getSearchResult(query: String, subBundle: MutableSet?): Pair, MutableSet> suspend fun getSearchSuggestions(query: String): List suspend fun getAppsByCategory(category: String, pageUrl: String? = null): Any suspend fun getCategories(type: CategoryType? = null): List diff --git a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt index 23d4e842771cba5651803d124090403875ffd95b..19461db72783b10456324a8ffd9187190530eb2b 100644 --- a/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/gplay/GplayStoreRepositoryImpl.kt @@ -39,11 +39,8 @@ import foundation.e.apps.data.fused.utils.CategoryType import foundation.e.apps.data.gplay.utils.GPlayHttpClient import foundation.e.apps.data.login.LoginSourceRepository import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject class GplayStoreRepositoryImpl @Inject constructor( @@ -78,75 +75,30 @@ class GplayStoreRepositoryImpl @Inject constructor( override suspend fun getSearchResult( query: String, - ): Flow, Boolean>> { - return flow { - - /* - * Variable names and logic made same as that of Aurora store. - * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171 - */ - var authData = loginSourceRepository.gplayAuth ?: return@flow - - val searchHelper = - SearchHelper(authData).using(gPlayHttpClient) - val searchBundle = searchHelper.searchResults(query) - - val initialReplacedList = mutableListOf() - val INITIAL_LIMIT = 4 - - emitReplacedList( - this@flow, - initialReplacedList, - INITIAL_LIMIT, - searchBundle, - true, - ) - - var nextSubBundleSet: MutableSet - do { - nextSubBundleSet = fetchNextSubBundle( - searchBundle, - searchHelper, - this@flow, - initialReplacedList, - INITIAL_LIMIT - ) - } while (nextSubBundleSet.isNotEmpty()) + subBundle: MutableSet? + ): Pair, MutableSet> { + var authData = loginSourceRepository.gplayAuth ?: return Pair(emptyList(), mutableSetOf()) + val searchHelper = + SearchHelper(authData).using(gPlayHttpClient) + + Timber.d("Fetching search result for $query, subBundle: $subBundle") + + subBundle?.let { + val searchResult = searchHelper.next(it) + Timber.d("fetching next page search data...") + return getSearchResultPair(searchResult) + } - /* - * If initialReplacedList size is less than INITIAL_LIMIT, - * it means the results were very less and nothing has been emitted so far. - * Hence emit the list. - */ - if (initialReplacedList.size < INITIAL_LIMIT) { - emitInMain(this@flow, initialReplacedList, false) - } - }.flowOn(Dispatchers.IO) + val searchResult = searchHelper.searchResults(query) + return getSearchResultPair(searchResult) } - private suspend fun fetchNextSubBundle( - searchBundle: SearchBundle, - searchHelper: SearchHelper, - scope: FlowCollector, Boolean>>, - accumulationList: MutableList, - accumulationLimit: Int, - ): MutableSet { - val nextSubBundleSet = searchBundle.subBundles - val newSearchBundle = searchHelper.next(nextSubBundleSet) - if (newSearchBundle.appList.isNotEmpty()) { - searchBundle.apply { - subBundles.clear() - subBundles.addAll(newSearchBundle.subBundles) - emitReplacedList( - scope, - accumulationList, - accumulationLimit, - newSearchBundle, - nextSubBundleSet.isNotEmpty(), - ) - } - } - return nextSubBundleSet + private fun getSearchResultPair( + searchBundle: SearchBundle + ): Pair, MutableSet> { + val apps = searchBundle.appList + Timber.d("Search result is found: ${apps.size}") + return Pair(apps, searchBundle.subBundles) } override suspend fun getSearchSuggestions(query: String): List { @@ -214,52 +166,6 @@ class GplayStoreRepositoryImpl @Inject constructor( return if (type == CategoryType.APPLICATION) Category.Type.APPLICATION else Category.Type.GAME } - private suspend fun emitReplacedList( - scope: FlowCollector, Boolean>>, - accumulationList: MutableList, - accumulationLimit: Int, - searchBundle: SearchBundle, - moreToEmit: Boolean, - ) { - searchBundle.appList.forEach { - when { - accumulationList.size < accumulationLimit - 1 -> { - /* - * If initial limit is 4, add apps to list (without emitting) - * till 2 apps. - */ - accumulationList.add(it) - } - - accumulationList.size == accumulationLimit - 1 -> { - /* - * If initial limit is 4, and we have reached till 3 apps, - * add the 4th app and emit the list. - */ - accumulationList.add(it) - scope.emit(Pair(accumulationList, moreToEmit)) - emitInMain(scope, accumulationList, moreToEmit) - } - - accumulationList.size == accumulationLimit -> { - /* - * If initial limit is 4, and we have emitted 4 apps, - * for all rest of the apps, emit each app one by one. - */ - emitInMain(scope, listOf(it), moreToEmit) - } - } - } - } - - private suspend fun emitInMain( - scope: FlowCollector, Boolean>>, - it: List, - moreToEmit: Boolean - ) { - scope.emit(Pair(it, moreToEmit)) - } - private suspend fun getTopApps( type: TopChartsHelper.Type, chart: Chart, diff --git a/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt b/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt index 33a3b837011870e1321ebc3981bbdf2fa677dba7..296b15d232b82538bbd6c09cfd908f4fb0956b63 100644 --- a/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt +++ b/app/src/main/java/foundation/e/apps/data/gplay/utils/GPlayHttpClient.kt @@ -41,6 +41,7 @@ import timber.log.Timber import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException +import java.util.concurrent.TimeUnit import javax.inject.Inject class GPlayHttpClient @Inject constructor( @@ -52,10 +53,13 @@ class GPlayHttpClient @Inject constructor( companion object { private const val TAG = "GPlayHttpClient" + private const val HTTP_TIMEOUT_IN_SECOND = 10L + private const val SEARCH = "search" } private val okHttpClient = OkHttpClient().newBuilder() .retryOnConnectionFailure(false) + .callTimeout(HTTP_TIMEOUT_IN_SECOND, TimeUnit.SECONDS) .followRedirects(true) .followSslRedirects(true) .cache(cache) @@ -155,6 +159,13 @@ class GPlayHttpClient @Inject constructor( val call = okHttpClient.newCall(request) buildPlayResponse(call.execute()) } catch (e: Exception) { + // TODO: exception will be thrown for all apis when all gplay api implementation + // will handle the exceptions. this will be done in following issue. + // Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/1483 + if (request.url.toString().contains(SEARCH)) { + throw e + } + when (e) { is UnknownHostException, is SocketTimeoutException -> handleExceptionOnGooglePlayRequest(e) @@ -185,6 +196,13 @@ class GPlayHttpClient @Inject constructor( Timber.d("$TAG: Url: ${response.request.url}\nStatus: $code") + // TODO: exception will be thrown for all apis when all gplay api implementation + // will handle the exceptions. this will be done in following issue. + // Issue: https://gitlab.e.foundation/e/os/backlog/-/issues/1483 + if (response.request.url.toString().contains(SEARCH) && code != 200) { + throw GplayHttpRequestException(code, response.message) + } + if (code == 401) { MainScope().launch { EventBus.invokeEvent( @@ -203,3 +221,5 @@ class GPlayHttpClient @Inject constructor( } } } + +class GplayHttpRequestException(val status: Int, message: String) : Exception(message) diff --git a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt index 604365df4bb0e23d89c5e368e6dff62eb3bfa45e..8abb4139857b5a698e688f15275c80e4ddd87c20 100644 --- a/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt +++ b/app/src/main/java/foundation/e/apps/install/workmanager/AppInstallProcessor.kt @@ -19,8 +19,6 @@ package foundation.e.apps.install.workmanager import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.os.Environment import android.os.StatFs import com.aurora.gplayapi.exceptions.ApiException @@ -41,6 +39,7 @@ import foundation.e.apps.install.updates.UpdatesNotifier import foundation.e.apps.utils.eventBus.AppEvent import foundation.e.apps.utils.eventBus.EventBus import foundation.e.apps.utils.getFormattedString +import foundation.e.apps.utils.isNetworkAvailable import kotlinx.coroutines.flow.transformWhile import timber.log.Timber import java.text.NumberFormat @@ -122,7 +121,7 @@ class AppInstallProcessor @Inject constructor( return } - if (!isNetworkAvailable()) { + if (!context.isNetworkAvailable()) { fusedManagerRepository.installationIssue(fusedDownload) EventBus.invokeEvent(AppEvent.NoInternetEvent(false)) return @@ -185,22 +184,6 @@ class AppInstallProcessor @Inject constructor( return statFs.availableBytes } - private fun isNetworkAvailable(): Boolean { - val connectivityManager = - context.getSystemService(ConnectivityManager::class.java) - val capabilities = - connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - ?: return false - - if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - ) { - return true - } - - return false - } - suspend fun processInstall( fusedDownloadId: String, isItUpdateWork: Boolean, diff --git a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt index 1799be2481dc2a225ae82c766f5e4bc92b5e236f..d28e864b0b900c172e98d821aa29dba99399f647 100644 --- a/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt +++ b/app/src/main/java/foundation/e/apps/ui/applicationlist/ApplicationListRVAdapter.kt @@ -115,6 +115,12 @@ class ApplicationListRVAdapter( onPlaceHolderShow?.invoke() // Do not process anything else for this entry return + } else { + val progressBar = holder.binding.placeholderProgressBar + holder.binding.root.children.forEach { + it.visibility = if (it != progressBar) View.VISIBLE + else View.INVISIBLE + } } holder.binding.apply { diff --git a/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt b/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt index 983577b5c2317098c5d01bf97c8fae5323d4c0c3..191ab13406083cf2f52c74d7d175d5a98cff126d 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt @@ -59,6 +59,7 @@ import foundation.e.apps.ui.PrivacyInfoViewModel import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.applicationlist.ApplicationListRVAdapter import foundation.e.apps.ui.parentFragment.TimeoutFragment +import foundation.e.apps.utils.isNetworkAvailable import kotlinx.coroutines.launch import javax.inject.Inject @@ -75,7 +76,7 @@ class SearchFragment : private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! - private val searchViewModel: SearchViewModel by viewModels() + protected val searchViewModel: SearchViewModel by viewModels() private val privacyInfoViewModel: PrivacyInfoViewModel by viewModels() private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels() override val mainActivityViewModel: MainActivityViewModel by activityViewModels() @@ -132,6 +133,18 @@ class SearchFragment : searchViewModel.exceptionsLiveData.observe(viewLifecycleOwner) { handleExceptionsCommon(it) } + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (!recyclerView.canScrollVertically(1)) { + if (!requireContext().isNetworkAvailable()) { + return + } + searchViewModel.loadMore(searchText) + } + } + }) } private fun shouldIgnore( diff --git a/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt b/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt index fc9d6022f050ced168b1a1a923743426846eee36..e1c740ccce4e249ca88f374ce45156176d08ebe7 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt @@ -24,16 +24,20 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.SearchBundle import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.fused.FusedAPIRepository +import foundation.e.apps.data.fused.GplaySearchResult import foundation.e.apps.data.fused.data.FusedApp import foundation.e.apps.data.login.AuthObject import foundation.e.apps.data.login.exceptions.CleanApkException import foundation.e.apps.data.login.exceptions.GPlayException +import foundation.e.apps.data.login.exceptions.UnknownSourceException import foundation.e.apps.ui.parentFragment.LoadingViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -42,13 +46,21 @@ class SearchViewModel @Inject constructor( ) : LoadingViewModel() { val searchSuggest: MutableLiveData?> = MutableLiveData() + val searchResult: MutableLiveData, Boolean>>> = MutableLiveData() private var searchResultLiveData: LiveData, Boolean>>> = MutableLiveData() - private var lastAuthObjects: List? = null + private var nextSubBundle: Set? = null + + private var isLoading: Boolean = false + + companion object { + private const val DATA_LOAD_ERROR = "Data load error" + } + fun getSearchSuggestions(query: String, gPlayAuth: AuthObject.GPlayAuth) { viewModelScope.launch(Dispatchers.IO) { if (gPlayAuth.result.isSuccess()) @@ -91,34 +103,81 @@ class SearchViewModel @Inject constructor( * 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) { - searchResultLiveData.removeObservers(lifecycleOwner) - searchResultLiveData = fusedAPIRepository.getSearchResults(query, authData) - searchResultLiveData.observe(lifecycleOwner) { - searchResult.postValue(it) - - if (!it.isSuccess()) { - val exception = - if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) { - GPlayException( - it.isTimeout(), - it.message.ifBlank { "Data load error" } - ) - } else { - CleanApkException( - it.isTimeout(), - it.message.ifBlank { "Data load error" } - ) - } - - exceptionsList.add(exception) - exceptionsLiveData.postValue(exceptionsList) - } + private fun getSearchResults( + query: String, + authData: AuthData, + lifecycleOwner: LifecycleOwner + ) { + viewModelScope.launch(Dispatchers.IO) { + val searchResultSupreme = fusedAPIRepository.getCleanApkSearchResults(query, authData) + + searchResult.postValue(searchResultSupreme) + + if (!searchResultSupreme.isSuccess()) { + val exception = + if (authData.aasToken.isNotBlank() || authData.authToken.isNotBlank()) { + GPlayException( + searchResultSupreme.isTimeout(), + searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } + ) + } else { + CleanApkException( + searchResultSupreme.isTimeout(), + searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } + ) + } + + handleException(exception) } + + nextSubBundle = null + fetchGplayData(query) } } + fun loadMore(query: String) { + if (isLoading) { + Timber.d("Search result is loading....") + return + } + + viewModelScope.launch(Dispatchers.IO) { + fetchGplayData(query) + } + } + + private suspend fun fetchGplayData(query: String) { + isLoading = true + val gplaySearchResult = fusedAPIRepository.getGplaySearchResults(query, nextSubBundle) + + if (!gplaySearchResult.isSuccess()) { + handleException(gplaySearchResult.exception ?: UnknownSourceException()) + } + + nextSubBundle = gplaySearchResult.data?.second + + val currentAppList = updateCurrentAppList(gplaySearchResult) + val finalResult = ResultSupreme.Success( + Pair(currentAppList.toList(), nextSubBundle?.isNotEmpty() ?: false) + ) + + this@SearchViewModel.searchResult.postValue(finalResult) + isLoading = false + } + + private fun updateCurrentAppList(gplaySearchResult: GplaySearchResult): MutableList { + val currentSearchResult = searchResult.value?.data + val currentAppList = currentSearchResult?.first?.toMutableList() ?: mutableListOf() + currentAppList.removeIf { item -> item.isPlaceHolder } + currentAppList.addAll(gplaySearchResult.data?.first ?: emptyList()) + return currentAppList + } + + private fun handleException(exception: Exception) { + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } + /** * @return returns true if there is changes in data, otherwise false */ diff --git a/app/src/main/java/foundation/e/apps/utils/Extensions.kt b/app/src/main/java/foundation/e/apps/utils/Extensions.kt index 047cd03a6b498934e9988647b12d7d32612de715..aa763f6a816a0d2f6d8d73b135c7e6a1528c247d 100644 --- a/app/src/main/java/foundation/e/apps/utils/Extensions.kt +++ b/app/src/main/java/foundation/e/apps/utils/Extensions.kt @@ -1,6 +1,8 @@ package foundation.e.apps.utils import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import androidx.appcompat.app.AlertDialog import foundation.e.apps.R import java.text.SimpleDateFormat @@ -23,3 +25,20 @@ fun Context.showGoogleSignInAlertDialog( .setNegativeButton(R.string.cancel) { _, _ -> onCancelClickListener() } .show() } + +fun Context.isNetworkAvailable(): Boolean { + val connectivityManager = + this.getSystemService(ConnectivityManager::class.java) + + val capabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: return false + + if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) { + return true + } + + return false +} diff --git a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt index 9858c4900a8c2962591a5b6f3dcd368162c95137..3096c444c0309461cf941da1d3d1209197c0deb6 100644 --- a/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt +++ b/app/src/test/java/foundation/e/apps/FusedApiImplTest.kt @@ -24,6 +24,7 @@ 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 com.aurora.gplayapi.data.models.SearchBundle import foundation.e.apps.data.cleanapk.data.categories.Categories import foundation.e.apps.data.cleanapk.data.search.Search import foundation.e.apps.data.cleanapk.repositories.CleanApkRepository @@ -40,10 +41,7 @@ import foundation.e.apps.data.gplay.GplayStoreRepository import foundation.e.apps.install.pkg.PWAManagerModule import foundation.e.apps.install.pkg.PkgManagerModule import foundation.e.apps.util.MainCoroutineRule -import foundation.e.apps.util.getOrAwaitValue import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After @@ -763,10 +761,8 @@ class FusedApiImplTest { preferenceManagerModule.isPWASelectedFake = true preferenceManagerModule.isOpenSourceelectedFake = true preferenceManagerModule.isGplaySelectedFake = true - val gplayFlow: Flow, Boolean>> = flowOf( - Pair( - listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), false - ) + val gplayFlow: Pair, MutableSet> = Pair( + listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), mutableSetOf() ) setupMockingSearchApp( @@ -774,7 +770,7 @@ class FusedApiImplTest { ) val searchResultLiveData = - fusedAPIImpl.getSearchResults("com.search.package", AUTH_DATA).getOrAwaitValue() + fusedAPIImpl.getCleanApkSearchResults("com.search.package", AUTH_DATA) val size = searchResultLiveData.data?.first?.size ?: -2 assertEquals("getSearchResult", 8, size) @@ -783,7 +779,7 @@ class FusedApiImplTest { private suspend fun setupMockingSearchApp( packageNameSearchResponse: Response?, gplayPackageResult: App, - gplayLivedata: Flow, Boolean>>, + gplayLivedata: Pair, MutableSet>, willThrowException: Boolean = false ) { Mockito.`when`(pwaManagerModule.getPwaStatus(any())).thenReturn(Status.UNAVAILABLE) @@ -816,9 +812,11 @@ class FusedApiImplTest { ) ).thenReturn(packageNameSearchResponse) - Mockito.`when`(fdroidWebInterface.getFdroidApp(any())).thenReturn(Response.error(404, "".toResponseBody(null))) + Mockito.`when`(fdroidWebInterface.getFdroidApp(any())) + .thenReturn(Response.error(404, "".toResponseBody(null))) - Mockito.`when`(gPlayAPIRepository.getSearchResult(eq("com.search.package"),)).thenReturn(gplayLivedata) + Mockito.`when`(gPlayAPIRepository.getSearchResult(eq("com.search.package"), null)) + .thenReturn(gplayLivedata) } @Ignore("Dependencies are not mockable") @@ -852,10 +850,8 @@ class FusedApiImplTest { val packageNameSearchResponse = Response.success(searchResult) val gplayPackageResult = App("com.search.package") - val gplayFlow: Flow, Boolean>> = flowOf( - Pair( - listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), false - ) + val gplayFlow: Pair, MutableSet> = Pair( + listOf(App("a.b.c"), App("c.d.e"), App("d.e.f"), App("d.e.g")), mutableSetOf() ) setupMockingSearchApp( @@ -867,7 +863,7 @@ class FusedApiImplTest { preferenceManagerModule.isGplaySelectedFake = true val searchResultLiveData = - fusedAPIImpl.getSearchResults("com.search.package", AUTH_DATA).getOrAwaitValue() + fusedAPIImpl.getCleanApkSearchResults("com.search.package", AUTH_DATA) val size = searchResultLiveData.data?.first?.size ?: -2 assertEquals("getSearchResult", 4, size)