diff --git a/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt b/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt index e48d717e25a477f3459abfea77c194f9deb65fb2..29670f642a42096777a36184714bf8970b6e8b8e 100644 --- a/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt +++ b/app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt @@ -27,7 +27,6 @@ import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.Status import foundation.e.apps.install.pkg.AppLoungePackageManager import foundation.e.apps.install.pkg.PwaManager -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -57,7 +56,6 @@ class ApplicationDataManager @Inject constructor( } suspend fun getAppFilterLevel(application: Application): FilterLevel { - Timber.i("getAppFilterLevel: $application") return when { application.package_name.isBlank() -> FilterLevel.UNKNOWN !application.isFree && application.price.isBlank() -> FilterLevel.UI 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 4543c30fd5eeb52475e69f206c68187d1e5d83a5..3ff4084a4683aaa9e7425361c07eeeb7e5f1e8fd 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 @@ -1,4 +1,5 @@ /* + * Copyright (C) 2025 e Foundation * Copyright (C) 2021-2024 MURENA SAS * * This program is free software: you can redistribute it and/or modify @@ -35,7 +36,9 @@ import androidx.cursoradapter.widget.SimpleCursorAdapter import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -58,9 +61,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.utils.isNetworkAvailable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.Locale import javax.inject.Inject @@ -103,8 +104,6 @@ class SearchFragment : */ private var searchText = "" - private var searchJob: Job? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSearchBinding.bind(view) @@ -128,8 +127,6 @@ class SearchFragment : setupSearchFilters() - initiateSearch() - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) @@ -144,18 +141,34 @@ class SearchFragment : } private fun observeSearchResult(listAdapter: ApplicationListRVAdapter?) { - searchViewModel.searchResult.observe(viewLifecycleOwner) { result -> - val apps = result.data?.first - - if (apps.isNullOrEmpty()) { - noAppsFoundLayout?.visibility = View.VISIBLE - } else { - listAdapter?.let { adapter -> - observeDownloadList(adapter) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + searchViewModel.searchUiState.collectLatest { state -> + when (state) { + is SearchResultsUiState.Loading -> { + noAppsFoundLayout?.visibility = View.GONE + showLoadingUi() + } + + is SearchResultsUiState.Success -> { + listAdapter?.let { observeDownloadList(it) } + updateSearchResult(listAdapter, state.apps) + observeScrollOfSearchResult(listAdapter) + } + + is SearchResultsUiState.Empty -> { + stopLoadingUi() + noAppsFoundLayout?.visibility = View.VISIBLE + listAdapter?.setData(emptyList()) + } + + is SearchResultsUiState.Error -> { + stopLoadingUi() + noAppsFoundLayout?.visibility = View.VISIBLE + } + } } } - updateSearchResult(listAdapter, apps ?: emptyList()) - observeScrollOfSearchResult(listAdapter) } } @@ -201,7 +214,7 @@ class SearchFragment : } private fun showData() { - stopLoadingUI() + stopLoadingUi() noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE } @@ -247,8 +260,12 @@ class SearchFragment : CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER ) - searchViewModel.searchSuggestions.observe(viewLifecycleOwner) { - it?.let { populateSuggestionsAdapter(it) } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + searchViewModel.searchSuggestions.collectLatest { + populateSuggestionsAdapter(it) + } + } } } @@ -257,7 +274,7 @@ class SearchFragment : binding.filterChipGroup.isSingleSelection = true val listener = OnCheckedChangeListener { _, _ -> - showLoadingUI() + showLoadingUi() searchViewModel.setFilterFlags( flagOpenSource = filterChipOpenSource.isChecked, flagPWA = filterChipPWA.isChecked, @@ -304,24 +321,25 @@ class SearchFragment : appInstallList: List, applicationListRVAdapter: ApplicationListRVAdapter ) { - val searchList = - searchViewModel.searchResult.value?.data?.first?.toMutableList() ?: emptyList() + val searchList = when (val state = searchViewModel.searchUiState.value) { + is SearchResultsUiState.Success -> state.apps.toMutableList() + else -> emptyList() + } mainActivityViewModel.updateStatusOfFusedApps(searchList, appInstallList) updateSearchResult(applicationListRVAdapter, searchList) } private fun initiateSearch() { - showLoadingUI() searchViewModel.loadData(searchText) } - private fun showLoadingUI() { + private fun showLoadingUi() { shimmerLayout?.visibility = View.VISIBLE shimmerLayout?.startShimmer() } - private fun stopLoadingUI() { + private fun stopLoadingUi() { shimmerLayout?.stopShimmer() shimmerLayout?.visibility = View.GONE } @@ -400,24 +418,16 @@ class SearchFragment : } override fun onQueryTextChange(newText: String?): Boolean { - newText?.takeIf { it.isNotEmpty() }?.let(::doDebouncedSearch) + searchViewModel.onQueryChanged(newText.orEmpty()) return true } - private fun doDebouncedSearch(text: String) { - searchJob?.cancel() - searchJob = lifecycleScope.launch(Dispatchers.Main.immediate) { - delay(SEARCH_DEBOUNCE_DELAY_MILLIS) - searchViewModel.getSearchSuggestions(text) - } - } - override fun onSuggestionSelect(position: Int): Boolean { return true } override fun onSuggestionClick(position: Int): Boolean { - searchViewModel.searchSuggestions.value?.let { + searchViewModel.searchSuggestions.value.let { if (it.isNotEmpty()) { searchView?.setQuery(it[position].suggestion, true) } @@ -483,7 +493,6 @@ class SearchFragment : } companion object { - private const val SEARCH_DEBOUNCE_DELAY_MILLIS = 500L private const val SCROLL_TO_TOP_DELAY_MILLIS = 100L } } diff --git a/app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt b/app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt new file mode 100644 index 0000000000000000000000000000000000000000..000d1a37d3f59bb5b18b73474d7dfa49afd9a6a1 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2025 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 foundation.e.apps.data.application.data.Application + +/** + * Represents UI state of search results. + * - Loading: when fetching or applying filters + * - Success: non-empty results + * - Empty: no results + * - Error: failure with optional message/throwable + */ +sealed interface SearchResultsUiState { + data object Loading : SearchResultsUiState + data class Success(val apps: List) : SearchResultsUiState + data object Empty : SearchResultsUiState + data class Error(val message: String? = null, val throwable: Throwable? = null) : + SearchResultsUiState +} 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 325cba474637cc02ac9fa83af7cbd6549d1bebc2..fdbb4e9adc2735854192ac0f00fea83451d37f5d 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 @@ -19,8 +19,6 @@ package foundation.e.apps.ui.search import androidx.annotation.GuardedBy -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,8 +32,18 @@ import foundation.e.apps.data.enums.Source import foundation.e.apps.data.exodus.repositories.IAppPrivacyInfoRepository import foundation.e.apps.data.exodus.repositories.PrivacyScoreRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -51,11 +59,13 @@ class SearchViewModel @Inject constructor( private val appPrivacyInfoRepository: IAppPrivacyInfoRepository ) : ViewModel() { - val searchSuggestions: MutableLiveData> = MutableLiveData() + companion object { + private const val SEARCH_DEBOUNCE_DELAY_MILLIS = 500L + } - private val _searchResult: MutableLiveData = - MutableLiveData() - val searchResult: LiveData = _searchResult + private val _searchUiState: MutableStateFlow = + MutableStateFlow(SearchResultsUiState.Loading) + val searchUiState: StateFlow = _searchUiState.asStateFlow() private var isLoading: Boolean = false @@ -67,6 +77,22 @@ class SearchViewModel @Inject constructor( private var flagOpenSource: Boolean = false private var flagPWA: Boolean = false + val query = MutableStateFlow("") + + fun onQueryChanged(value: String) { + query.value = value + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val searchSuggestions: StateFlow> = query + .debounce(SEARCH_DEBOUNCE_DELAY_MILLIS) + .mapLatest { + if (it.isBlank()) emptyList() + else searchRepository.getSearchSuggestions(it) + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + fun setFilterFlags( flagNoTrackers: Boolean = false, flagOpenSource: Boolean = false, @@ -81,39 +107,19 @@ class SearchViewModel @Inject constructor( } } - fun getSearchSuggestions(query: String) { - viewModelScope.launch(Dispatchers.IO) { - searchSuggestions.postValue( - searchRepository.getSearchSuggestions(query) - ) - } - } - fun loadData(query: String) { - if (query.isBlank()) return + _searchUiState.value = SearchResultsUiState.Loading viewModelScope.launch { mutex.withLock { accumulatedList.clear() } - } - _searchResult.postValue( - ResultSupreme.Success( - Pair(emptyList(), false) - ) - ) - - fetchCleanApkData(query) - fetchGplayData(query) + fetchCleanApkData(query) + fetchPlayStoreData(query) + } } - /* - * 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 fetchCleanApkData(query: String) { viewModelScope.launch(Dispatchers.IO) { val searchResults = searchRepository.getOpenSourceSearchResults(query) @@ -127,7 +133,7 @@ class SearchViewModel @Inject constructor( Timber.d("Search result is loading....") return@launch } - fetchGplayData(query) + fetchPlayStoreData(query) } } @@ -135,7 +141,7 @@ class SearchViewModel @Inject constructor( return apps.filter { it.name.isNotBlank() }.sortedBy { it.source }.distinctBy { it.package_name } } - private fun fetchGplayData(query: String) { + private fun fetchPlayStoreData(query: String) { viewModelScope.launch(Dispatchers.IO) { isLoading = true @@ -215,34 +221,24 @@ class SearchViewModel @Inject constructor( /** * 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 - } - + private suspend fun emitFilteredResults(result: SearchResult? = null) { if (result != null && !result.isSuccess()) { - _searchResult.postValue(result!!) + _searchUiState.value = SearchResultsUiState.Error(result.message, result.exception) return } if (result != null) { - result.data?.first?.let { - mutex.withLock { - accumulatedList.addAll(it) - } + result.data?.first?.let { newItems -> + mutex.withLock { accumulatedList.addAll(newItems) } } } val filteredList = getFilteredList() - _searchResult.postValue( - ResultSupreme.Success( - Pair(filteredList.toList(), false) - ) - ) + _searchUiState.value = if (filteredList.isEmpty()) { + SearchResultsUiState.Empty + } else { + SearchResultsUiState.Success(filteredList.toList()) + } } }