diff --git a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt index 5808dda5929e96a236d8ef98de230ec8bc596e3c..a43a4e84136ed33d9407c06ef582ac5e2fc1dddf 100644 --- a/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt @@ -39,6 +39,7 @@ import foundation.e.apps.data.application.search.GplaySearchResult import foundation.e.apps.data.application.search.SearchApi import foundation.e.apps.data.application.utils.CategoryType import foundation.e.apps.data.fusedDownload.models.FusedDownload +import foundation.e.apps.ui.search.SearchResult import javax.inject.Inject import javax.inject.Singleton @@ -119,7 +120,7 @@ class ApplicationRepository @Inject constructor( suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): ResultSupreme, Boolean>> { + ): SearchResult { return searchAPIImpl.getCleanApkSearchResults(query, authData) } diff --git a/app/src/main/java/foundation/e/apps/data/application/search/SearchApi.kt b/app/src/main/java/foundation/e/apps/data/application/search/SearchApi.kt index f539f613cb34cd46bee944f2b8212409d4c4538a..34898eefdafd415022b3908f65b125427ed713ce 100644 --- a/app/src/main/java/foundation/e/apps/data/application/search/SearchApi.kt +++ b/app/src/main/java/foundation/e/apps/data/application/search/SearchApi.kt @@ -23,6 +23,7 @@ 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.application.data.Application +import foundation.e.apps.ui.search.SearchResult typealias GplaySearchResult = ResultSupreme, Set>> @@ -45,7 +46,7 @@ interface SearchApi { suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): ResultSupreme, Boolean>> + ): SearchResult suspend fun getGplaySearchResult( query: String, diff --git a/app/src/main/java/foundation/e/apps/data/application/search/SearchApiImpl.kt b/app/src/main/java/foundation/e/apps/data/application/search/SearchApiImpl.kt index c7379ff436a1d1623fdfe120b64a845a43c47c7a..bf19c7af8daf2985507964052da7b16fe270cb48 100644 --- a/app/src/main/java/foundation/e/apps/data/application/search/SearchApiImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/application/search/SearchApiImpl.kt @@ -39,6 +39,7 @@ import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.login.AuthObject import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.ui.search.SearchResult import foundation.e.apps.utils.eventBus.AppEvent import foundation.e.apps.utils.eventBus.EventBus import kotlinx.coroutines.Deferred @@ -85,9 +86,8 @@ class SearchApiImpl @Inject constructor( override suspend fun getCleanApkSearchResults( query: String, authData: AuthData - ): ResultSupreme, Boolean>> { - var finalSearchResult: ResultSupreme, Boolean>> = - ResultSupreme.Error() + ): SearchResult { + var finalSearchResult: SearchResult = ResultSupreme.Error() val packageSpecificResults = fetchPackageSpecificResult(authData, query).data?.first ?: emptyList() @@ -125,7 +125,7 @@ class SearchApiImpl @Inject constructor( query: String, searchResult: MutableList, packageSpecificResults: List - ): ResultSupreme, Boolean>> { + ): SearchResult { val pwaApps: MutableList = mutableListOf() val result = handleNetworkResult { val apps = @@ -159,7 +159,7 @@ class SearchApiImpl @Inject constructor( query: String, searchResult: MutableList, packageSpecificResults: List - ): ResultSupreme, Boolean>> { + ): SearchResult { val cleanApkResults = mutableListOf() val result = handleNetworkResult { @@ -187,7 +187,7 @@ class SearchApiImpl @Inject constructor( private suspend fun fetchPackageSpecificResult( authData: AuthData, query: String, - ): ResultSupreme, Boolean>> { + ): SearchResult { val packageSpecificResults: MutableList = mutableListOf() var gplayPackageResult: Application? = null var cleanapkPackageResult: Application? = null diff --git a/app/src/main/java/foundation/e/apps/ui/PrivacyInfoViewModel.kt b/app/src/main/java/foundation/e/apps/ui/PrivacyInfoViewModel.kt index 46c07546d42ba0837db593d0dceda8c88101e5ef..e74a72737fba32723f341dbf412466c29a2b059b 100644 --- a/app/src/main/java/foundation/e/apps/ui/PrivacyInfoViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/PrivacyInfoViewModel.kt @@ -29,6 +29,10 @@ class PrivacyInfoViewModel @Inject constructor( } } + suspend fun getAppPrivacyInfo(application: Application): Result { + return fetchEmitAppPrivacyInfo(application) + } + fun getSingularAppPrivacyInfoLiveData(application: Application?): LiveData> { fetchPrivacyInfo(application) return singularAppPrivacyInfoLiveData 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 40572c6a3897711eab25dd8bc860bcf81d06727d..43cd6685181d946d211d9f8d86ecc6fe5a2b9280 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 @@ -535,6 +535,9 @@ class ApplicationListRVAdapter( currentList.forEach { newList.find { item -> item._id == it._id }?.let { foundItem -> foundItem.privacyScore = it.privacyScore + foundItem.trackers = it.trackers + foundItem.perms = it.perms + foundItem.permsFromExodus = it.permsFromExodus } } this.submitList(newList.map { it.copy() }) 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 0c743352eba41ddc2361c260b3297255fd60b197..45aaa7cfdf8de3af5d41cbe4daf85310d81f59e2 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 @@ -25,6 +25,7 @@ import android.os.Bundle import android.provider.BaseColumns import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.EditText import android.widget.ImageView import android.widget.LinearLayout @@ -40,6 +41,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.aurora.gplayapi.SearchSuggestEntry import com.facebook.shimmer.ShimmerFrameLayout +import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.R import foundation.e.apps.data.enums.Status @@ -90,6 +92,10 @@ class SearchFragment : private var searchHintLayout: LinearLayout? = null private var noAppsFoundLayout: LinearLayout? = null + lateinit var filterChipNoTrackers: Chip + lateinit var filterChipOpenSource: Chip + lateinit var filterChipPWA: Chip + /* * Store the string from onQueryTextSubmit() and access it from loadData() * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 @@ -106,14 +112,21 @@ class SearchFragment : searchHintLayout = binding.searchHintLayout.root noAppsFoundLayout = binding.noAppsFoundLayout.root + filterChipNoTrackers = binding.filterChipNoTrackers + filterChipOpenSource = binding.filterChipOpenSource + filterChipPWA = binding.filterChipPWA + setupSearchView() setupSearchViewSuggestions() // Setup Search Results val listAdapter = setupSearchResult(view) + preventLoadingLessResults() observeSearchResult(listAdapter) + setupSearchFilters() + setupListening() authObjects.observe(viewLifecycleOwner) { @@ -172,6 +185,14 @@ class SearchFragment : } } + private fun preventLoadingLessResults() { + searchViewModel.gplaySearchLoaded.observe(viewLifecycleOwner) { + if (!it) return@observe + + searchViewModel.loadMoreDataIfNeeded(searchText) + } + } + private fun observeScrollOfSearchResult(listAdapter: ApplicationListRVAdapter?) { listAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { @@ -255,6 +276,26 @@ class SearchFragment : } } + private fun setupSearchFilters() { + + binding.filterChipGroup.isSingleSelection = true + + val listener = OnCheckedChangeListener { _, _ -> + showLoadingUI() + searchViewModel.setFilterFlags( + flagNoTrackers = filterChipNoTrackers.isChecked, + flagOpenSource = filterChipOpenSource.isChecked, + flagPWA = filterChipPWA.isChecked, + ) + + recyclerView?.scrollToPosition(0) + } + + filterChipNoTrackers.setOnCheckedChangeListener(listener) + filterChipOpenSource.setOnCheckedChangeListener(listener) + filterChipPWA.setOnCheckedChangeListener(listener) + } + private fun setupSearchView() { setHasOptionsMenu(true) searchView?.setOnSuggestionListener(this) 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 c46fa48472ac16231d804fa0649d3302982eead5..2f8e171c0115a9870ce97478fda7819a0cc56fdd 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 @@ -1,203 +1,322 @@ -/* - * Apps Quickly and easily install Android apps onto your device! - * Copyright (C) 2021 E FOUNDATION - * - * 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.ui.search - -import androidx.lifecycle.LifecycleOwner -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.application.ApplicationRepository -import foundation.e.apps.data.application.search.GplaySearchResult -import foundation.e.apps.data.application.data.Application -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.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject -import kotlin.coroutines.coroutineContext - -@HiltViewModel -class SearchViewModel @Inject constructor( - private val applicationRepository: ApplicationRepository, -) : LoadingViewModel() { - - val searchSuggest: MutableLiveData?> = MutableLiveData() - - val searchResult: MutableLiveData, 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()) - searchSuggest.postValue( - applicationRepository.getSearchSuggestions(query) - ) - } - } - - fun loadData( - query: String, - lifecycleOwner: LifecycleOwner, - authObjectList: List, - retryBlock: (failedObjects: List) -> Boolean - ) { - - if (query.isBlank()) return - - this.lastAuthObjects = authObjectList - super.onLoadData(authObjectList, { successAuthList, _ -> - - successAuthList.find { it is AuthObject.GPlayAuth }?.run { - getSearchResults(query, result.data!! as AuthData) - return@onLoadData - } - - successAuthList.find { it is AuthObject.CleanApk }?.run { - getSearchResults(query, null) - return@onLoadData - } - }, retryBlock) - } - - /* - * 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 - */ - private fun getSearchResults( - query: String, - authData: AuthData? - ) { - viewModelScope.launch(Dispatchers.IO) { - val searchResultSupreme = applicationRepository.getCleanApkSearchResults( - query, - authData ?: AuthData("", "") - ) - - searchResult.postValue(searchResultSupreme) - - if (!searchResultSupreme.isSuccess()) { - val exception = - if (authData != null) { - GPlayException( - searchResultSupreme.isTimeout(), - searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } - ) - } else { - CleanApkException( - searchResultSupreme.isTimeout(), - searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } - ) - } - - handleException(exception) - } - - if (authData == null) { - return@launch - } - - 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 = applicationRepository.getGplaySearchResults(query, nextSubBundle) - - if (!gplaySearchResult.isSuccess()) { - handleException(gplaySearchResult.exception ?: UnknownSourceException()) - } - - val isFirstFetch = nextSubBundle == null - nextSubBundle = gplaySearchResult.data?.second - - // first page has less data, then fetch next page data without waiting for users' scroll - if (isFirstFetch && gplaySearchResult.isSuccess()) { - CoroutineScope(coroutineContext).launch { - fetchGplayData(query) - } - } - - 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): List { - val currentSearchResult = searchResult.value?.data - val currentAppList = currentSearchResult?.first?.toMutableList() ?: mutableListOf() - currentAppList.removeIf { item -> item.isPlaceHolder } - currentAppList.addAll(gplaySearchResult.data?.first ?: emptyList()) - return currentAppList.distinctBy { it.package_name } - } - - private fun handleException(exception: Exception) { - exceptionsList.add(exception) - exceptionsLiveData.postValue(exceptionsList) - } - - /** - * @return returns true if there is changes in data, otherwise false - */ - fun isAnyAppUpdated( - newApplications: List, - oldApplications: List - ) = applicationRepository.isAnyFusedAppUpdated(newApplications, oldApplications) - - fun isAuthObjectListSame(authObjectList: List?): Boolean { - return lastAuthObjects == authObjectList - } -} +/* + * Apps Quickly and easily install Android apps onto your device! + * Copyright (C) 2021 E FOUNDATION + * + * 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.ui.search + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +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.application.ApplicationRepository +import foundation.e.apps.data.application.search.GplaySearchResult +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Origin +import foundation.e.apps.data.exodus.repositories.IAppPrivacyInfoRepository +import foundation.e.apps.data.exodus.repositories.PrivacyScoreRepository +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.di.CommonUtilsModule.LIST_OF_NULL +import foundation.e.apps.ui.parentFragment.LoadingViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +typealias SearchResult = ResultSupreme, Boolean>> + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val applicationRepository: ApplicationRepository, + private val privacyScoreRepository: PrivacyScoreRepository, + private val appPrivacyInfoRepository: IAppPrivacyInfoRepository +) : LoadingViewModel() { + + val searchSuggest: MutableLiveData?> = MutableLiveData() + + private val _searchResult: MutableLiveData = + MutableLiveData() + val searchResult: LiveData = _searchResult + + val gplaySearchLoaded : MutableLiveData = MutableLiveData(false) + + private var lastAuthObjects: List? = null + + private var nextSubBundle: Set? = null + + private var isLoading: Boolean = false + private var hasGPlayBeenFetched = false + + val accumulatedList = mutableListOf() + + private var flagNoTrackers: Boolean = false + private var flagOpenSource: Boolean = false + private var flagPWA: Boolean = false + + companion object { + private const val DATA_LOAD_ERROR = "Data load error" + private const val MIN_SEARCH_DISPLAY_ITEMS = 10 + private const val PREVENT_HTTP_429_DELAY_IN_MS = 1000L + } + + fun setFilterFlags( + flagNoTrackers: Boolean = false, + flagOpenSource: Boolean = false, + flagPWA: Boolean = false, + ) { + this.flagNoTrackers = flagNoTrackers + this.flagOpenSource = flagOpenSource + this.flagPWA = flagPWA + + viewModelScope.launch { + emitFilteredResults(null) + } + } + + fun getSearchSuggestions(query: String, gPlayAuth: AuthObject.GPlayAuth) { + viewModelScope.launch(Dispatchers.IO) { + if (gPlayAuth.result.isSuccess()) + searchSuggest.postValue( + applicationRepository.getSearchSuggestions(query) + ) + } + } + + fun loadData( + query: String, + lifecycleOwner: LifecycleOwner, + authObjectList: List, + retryBlock: (failedObjects: List) -> Boolean + ) { + + if (query.isBlank()) return + + this.lastAuthObjects = authObjectList + super.onLoadData(authObjectList, { successAuthList, _ -> + + successAuthList.find { it is AuthObject.GPlayAuth }?.run { + getSearchResults(query, result.data!! as AuthData) + return@onLoadData + } + + successAuthList.find { it is AuthObject.CleanApk }?.run { + getSearchResults(query, null) + return@onLoadData + } + }, retryBlock) + } + + /* + * 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 + */ + private fun getSearchResults( + query: String, + authData: AuthData? + ) { + viewModelScope.launch(Dispatchers.IO) { + val searchResultSupreme = applicationRepository.getCleanApkSearchResults( + query, + authData ?: AuthData("", "") + ) + + hasGPlayBeenFetched = false + emitFilteredResults(searchResultSupreme) + + if (!searchResultSupreme.isSuccess()) { + val exception = + if (authData != null) { + GPlayException( + searchResultSupreme.isTimeout(), + searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } + ) + } else { + CleanApkException( + searchResultSupreme.isTimeout(), + searchResultSupreme.message.ifBlank { DATA_LOAD_ERROR } + ) + } + + handleException(exception) + } + + if (authData == null) { + return@launch + } + + nextSubBundle = null + fetchGplayData(query) + } + } + + fun loadMore(query: String, autoTriggered: Boolean = false) { + if (isLoading) { + Timber.d("Search result is loading....") + return + } + + viewModelScope.launch(Dispatchers.IO) { + if (autoTriggered) { + delay(PREVENT_HTTP_429_DELAY_IN_MS) + } + fetchGplayData(query) + } + } + + private suspend fun fetchGplayData(query: String) { + isLoading = true + val gplaySearchResult = applicationRepository.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) + ) + + hasGPlayBeenFetched = true + emitFilteredResults(finalResult) + + isLoading = false + } + + private fun updateCurrentAppList(gplaySearchResult: GplaySearchResult): List { + val currentAppList = accumulatedList + currentAppList.removeIf { item -> item.isPlaceHolder } + currentAppList.addAll(gplaySearchResult.data?.first ?: emptyList()) + return currentAppList.distinctBy { it.package_name } + } + + private fun handleException(exception: Exception) { + exceptionsList.add(exception) + exceptionsLiveData.postValue(exceptionsList) + } + + /** + * @return returns true if there is changes in data, otherwise false + */ + fun isAnyAppUpdated( + newApplications: List, + oldApplications: List + ) = applicationRepository.isAnyFusedAppUpdated(newApplications, oldApplications) + + fun isAuthObjectListSame(authObjectList: List?): Boolean { + return lastAuthObjects == authObjectList + } + + private fun hasTrackers(app: Application): Boolean { + return when { + app.trackers == LIST_OF_NULL -> true // Tracker data unavailable, don't show + app.trackers.isNotEmpty() -> true // Trackers present + app.privacyScore == 0 -> true // Manually blocked apps (Facebook etc.) + else -> false + } + } + + private suspend fun fetchTrackersForApp(app: Application) { + if (app.isPlaceHolder) return + appPrivacyInfoRepository.getAppPrivacyInfo(app, app.package_name).let { + val calculatedScore = privacyScoreRepository.calculatePrivacyScore(app) + app.privacyScore = calculatedScore + } + } + + private suspend fun getFilteredList(): List = withContext(IO) { + if (flagNoTrackers) { + val deferredCheck = accumulatedList.map { + async { + if (it.privacyScore == -1) { + fetchTrackersForApp(it) + } + it + } + } + deferredCheck.awaitAll() + } + + accumulatedList.filter { + if (!flagNoTrackers && !flagOpenSource && !flagPWA) return@filter true + if (flagNoTrackers && !hasTrackers(it)) return@filter true + if (flagOpenSource && !it.is_pwa && it.origin == Origin.CLEANAPK) return@filter true + if (flagPWA && it.is_pwa) return@filter true + false + } + } + + /** + * Pass [result] as null to re-emit already loaded search results with new filters. + */ + private suspend fun emitFilteredResults( + result: SearchResult? = null + ) { + + // When filters are changed but no data is fetched yet + if (result == null && _searchResult.value == null) { + return + } + + if (result != null && !result.isSuccess()) { + _searchResult.postValue(result!!) + return + } + + if (result != null) { + result.data?.first?.let { + accumulatedList.clear() + accumulatedList.addAll(it) + } + } + + val filteredList = getFilteredList() + val isMoreDataLoading = result?.data?.second ?: _searchResult.value?.data?.second ?: false + + _searchResult.postValue( + ResultSupreme.Success( + Pair(filteredList.toList(), isMoreDataLoading) + ) + ) + gplaySearchLoaded.postValue(hasGPlayBeenFetched) + } + + fun loadMoreDataIfNeeded(searchText: String) { + val searchList = + searchResult.value?.data?.first?.toMutableList() ?: emptyList() + val canLoadMore = searchResult.value?.data?.second ?: false + + if (searchList.size < MIN_SEARCH_DISPLAY_ITEMS && canLoadMore) { + loadMore(searchText, autoTriggered = true) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesViewModel.kt b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesViewModel.kt index 12efeb8f643e8539a98668f74953002d4b3a72da..77214dc202ba5ba409265800f313295f2fe0b858 100644 --- a/app/src/main/java/foundation/e/apps/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/updates/UpdatesViewModel.kt @@ -57,7 +57,7 @@ class UpdatesViewModel @Inject constructor( } successAuthList.find { it is AuthObject.CleanApk }?.run { - getUpdates(AuthData("", "")) + getUpdates(null) return@onLoadData } }, retryBlock) diff --git a/app/src/main/res/color/chip_background_color.xml b/app/src/main/res/color/chip_background_color.xml new file mode 100644 index 0000000000000000000000000000000000000000..4b121e9befb1789e520412c91bc2cfb271eb2ee6 --- /dev/null +++ b/app/src/main/res/color/chip_background_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/chip_border_color.xml b/app/src/main/res/color/chip_border_color.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e1ffb3426461537d0dbdd14d29a32b220ba16a7 --- /dev/null +++ b/app/src/main/res/color/chip_border_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/chip_text_color.xml b/app/src/main/res/color/chip_text_color.xml new file mode 100644 index 0000000000000000000000000000000000000000..e4b3fed7421a25c85578124acdab019d56753dfa --- /dev/null +++ b/app/src/main/res/color/chip_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 07322322d77375990043c8a6931582c822ee18ec..49dc21378d9a0d4a6f6fdae8de472590c4a26412 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -48,19 +48,55 @@ android:layout_marginTop="8dp" android:background="@color/colorGrey" /> + + + + + + + + + + + + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aeb2e0597e9d5c5724e582f904f09e7f209ed972..6a53870477a514909d346408782e8505f30e228d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ Search for an app No apps found… + No Trackers + Applications Games diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 1523963d7bc133cbf4c0a663befc70e5da905534..dc5f734d83f4626f648cfbe42846b27ff48dfd10 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -71,4 +71,17 @@ @color/install_button_background + + \ No newline at end of file