Merge branch 'migration/gplayapi-3.5.4' into 'dev'

gplayapi:3.5.5

See merge request AuroraOSS/AuroraStore!487
This commit is contained in:
Rahul Patel
2025-06-01 20:10:23 +05:30
6 changed files with 381 additions and 313 deletions

View File

@@ -0,0 +1,80 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* Aurora Store 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 2 of the License, or
* (at your option) any later version.
*
* Aurora Store 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 Aurora Store. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.aurora.store.view.epoxy.controller
import com.airbnb.epoxy.TypedEpoxyController
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.store.R
import com.aurora.store.view.epoxy.controller.GenericCarouselController.Callbacks
import com.aurora.store.view.epoxy.groups.CarouselModelGroup
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
import com.aurora.store.view.epoxy.views.app.NoAppViewModel_
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
open class SearchCarouselController(private val callbacks: Callbacks) :
TypedEpoxyController<StreamBundle?>() {
override fun buildModels(streamBundle: StreamBundle?) {
setFilterDuplicates(true)
if (streamBundle == null) {
for (i in 1..6) {
add(
AppListViewShimmerModel_()
.id(i)
)
}
} else {
if (streamBundle.streamClusters.isEmpty()) {
add(
NoAppViewModel_()
.id("no_app")
.icon(R.drawable.ic_apps)
.message(R.string.no_apps_available)
)
} else {
streamBundle.streamClusters.values
.filter { it.clusterAppList.isNotEmpty() } // Filter out empty clusters, mostly related keywords
.forEach {
if (it.clusterTitle.isEmpty() or (it.clusterTitle == streamBundle.streamTitle)) {
if (it.clusterAppList.isNotEmpty()) {
it.clusterAppList.forEach { app ->
add(
AppListViewModel_()
.id(app.id)
.app(app)
.click { _ -> callbacks.onAppClick(app) }
)
}
}
} else {
add(CarouselModelGroup(it, callbacks))
}
}
if (streamBundle.hasNext())
add(
AppListViewShimmerModel_()
.id("progress")
)
}
}
}
}

View File

@@ -24,68 +24,84 @@ import android.view.View
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.aurora.gplayapi.data.models.SearchBundle import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.AppStreamStash
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.model.ViewState.Loading.getDataAs
import com.aurora.store.databinding.FragmentGenericWithToolbarBinding import com.aurora.store.databinding.FragmentGenericWithToolbarBinding
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
import com.aurora.store.view.epoxy.views.AppProgressViewModel_ import com.aurora.store.view.epoxy.controller.GenericCarouselController
import com.aurora.store.view.epoxy.views.app.AppListViewModel_ import com.aurora.store.view.epoxy.controller.SearchCarouselController
import com.aurora.store.view.ui.commons.BaseFragment import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.search.SearchResultViewModel import com.aurora.store.viewmodel.search.SearchResultViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>() { class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>(),
GenericCarouselController.Callbacks {
private val args: DevAppsFragmentArgs by navArgs() private val args: DevAppsFragmentArgs by navArgs()
private val viewModel: SearchResultViewModel by viewModels() private val viewModel: SearchResultViewModel by viewModels()
private val controller = SearchCarouselController(this)
private var query: String = ""
get() = "pub:${args.developerName}"
private var scrollListener: EndlessRecyclerOnScrollListener =
object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
override fun onLoadMore(currentPage: Int) {
viewModel.observe(query)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.liveData.observe(viewLifecycleOwner) { with(binding) {
updateController(it) toolbar.apply {
} title = args.developerName
setNavigationOnClickListener { findNavController().navigateUp() }
// Toolbar
binding.toolbar.apply {
title = args.developerName
setNavigationOnClickListener { findNavController().navigateUp() }
}
// Recycler View
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() {
override fun onLoadMore(currentPage: Int) {
viewModel.liveData.value?.let { viewModel.next(it.subBundles) }
} }
recycler.setController(controller)
recycler.addOnScrollListener(scrollListener)
} }
binding.recycler.addOnScrollListener(endlessRecyclerOnScrollListener)
viewModel.observeSearchResults("pub:${args.developerName}") with(viewModel) {
} search("pub:${args.developerName}")
private fun updateController(searchBundle: SearchBundle) { liveData.observe(viewLifecycleOwner) {
binding.recycler when (it) {
.withModels { is ViewState.Loading -> {
setFilterDuplicates(true) controller.setData(null)
searchBundle.appList
.filter { it.displayName.isNotEmpty() }
.forEach { app ->
add(
AppListViewModel_()
.id(app.id)
.app(app)
.click(View.OnClickListener {
openDetailsFragment(app.packageName, app)
})
)
} }
if (searchBundle.subBundles.isNotEmpty()) { is ViewState.Success<*> -> {
add( val stash = it.getDataAs<AppStreamStash>()
AppProgressViewModel_() controller.setData(stash[query])
.id("progress") }
)
else -> {}
} }
} }
}
}
override fun onHeaderClicked(streamCluster: StreamCluster) {
openStreamBrowseFragment(streamCluster)
}
override fun onClusterScrolled(streamCluster: StreamCluster) {
viewModel.observeCluster(query, streamCluster)
}
override fun onAppClick(app: App) {
openDetailsFragment(app.packageName, app)
}
override fun onAppLongClick(app: App) {
} }
} }

View File

@@ -19,202 +19,100 @@
package com.aurora.store.view.ui.search package com.aurora.store.view.ui.search
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.TextView import android.widget.TextView
import androidx.core.view.ViewCompat import androidx.fragment.app.activityViewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.aurora.extensions.hideKeyboard import com.aurora.extensions.hideKeyboard
import com.aurora.extensions.showKeyboard import com.aurora.extensions.showKeyboard
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.SearchBundle import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.AppStreamStash
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.model.Filter import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.FilterProvider.Companion.PREFERENCE_FILTER import com.aurora.store.data.model.ViewState.Loading.getDataAs
import com.aurora.store.databinding.FragmentSearchResultBinding import com.aurora.store.databinding.FragmentSearchResultBinding
import com.aurora.store.util.Preferences
import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener
import com.aurora.store.view.epoxy.views.AppProgressViewModel_ import com.aurora.store.view.epoxy.controller.GenericCarouselController
import com.aurora.store.view.epoxy.views.app.AppListViewModel_ import com.aurora.store.view.epoxy.controller.SearchCarouselController
import com.aurora.store.view.epoxy.views.app.NoAppViewModel_
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
import com.aurora.store.view.ui.commons.BaseFragment import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.search.SearchResultViewModel import com.aurora.store.viewmodel.search.SearchResultViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(), class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(),
OnSharedPreferenceChangeListener { GenericCarouselController.Callbacks {
private val viewModel: SearchResultViewModel by viewModels() private val viewModel: SearchResultViewModel by activityViewModels()
private val controller = SearchCarouselController(this)
private lateinit var sharedPreferences: SharedPreferences private var query: String
get() = requireArguments().getString("query").orEmpty()
set(value) = requireArguments().putString("query", value)
private var query: String? = null private var scrollListener: EndlessRecyclerOnScrollListener =
private var searchBundle: SearchBundle = SearchBundle() object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
override fun onLoadMore(currentPage: Int) {
private var shimmerAnimationVisible = false viewModel.observe(query)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// Adjust FAB margins for edgeToEdge display
ViewCompat.setOnApplyWindowInsetsListener(binding.filterFab) { _, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
binding.filterFab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.margin_normal)
}
WindowInsetsCompat.CONSUMED
}
sharedPreferences = Preferences.getPrefs(view.context)
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
// Toolbar
binding.toolbar.apply {
setNavigationOnClickListener {
binding.searchBar.hideKeyboard()
findNavController().navigateUp()
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_clear -> {
binding.searchBar.text?.clear()
binding.searchBar.showKeyboard()
}
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
}
true
}
}
// Search // Search
attachSearch() attachSearch()
with(binding) {
// RecyclerView toolbar.apply {
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() { setNavigationOnClickListener {
override fun onLoadMore(currentPage: Int) { binding.searchBar.hideKeyboard()
viewModel.next(searchBundle.subBundles) findNavController().navigateUp()
}
}
binding.recycler.addOnScrollListener(endlessRecyclerOnScrollListener)
// Filter
binding.filterFab.setOnClickListener {
findNavController().navigate(R.id.filterSheet)
}
viewModel.liveData.observe(viewLifecycleOwner) {
if (shimmerAnimationVisible) {
endlessRecyclerOnScrollListener.resetPageCount()
binding.recycler.clear()
shimmerAnimationVisible = false
}
searchBundle = it
updateController(searchBundle)
}
query = requireArguments().getString("query")
// Don't fetch search results again when coming back to fragment
if (searchBundle.appList.isEmpty()) {
query?.let { updateQuery(it) }
} else {
updateController(searchBundle)
}
}
override fun onDestroyView() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
super.onDestroyView()
}
override fun onDestroy() {
viewModel.filterProvider.saveFilter(Filter())
super.onDestroy()
}
private fun updateController(searchBundle: SearchBundle?) {
if (searchBundle == null) {
shimmerAnimationVisible = true
binding.recycler.withModels {
for (i in 1..10) {
add(AppListViewShimmerModel_().id(i))
} }
} setOnMenuItemClickListener {
return when (it.itemId) {
} R.id.action_clear -> {
binding.searchBar.text?.clear()
val filteredAppList = filter(searchBundle.appList) binding.searchBar.showKeyboard()
if (filteredAppList.isEmpty()) {
if (searchBundle.subBundles.isNotEmpty()) {
viewModel.next(searchBundle.subBundles)
binding.recycler.withModels {
setFilterDuplicates(true)
add(
AppProgressViewModel_()
.id("progress")
)
}
} else {
binding.recycler.adapter?.let {
/*Show empty search list if nothing found or no app matches filter criterion*/
if (it.itemCount == 1 && searchBundle.subBundles.isEmpty()) {
binding.recycler.withModels {
add(
NoAppViewModel_()
.id("no_app")
.message(R.string.details_no_app_match)
.icon(R.drawable.ic_round_search)
)
} }
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
} }
} true
}
} else {
binding.recycler.withModels {
setFilterDuplicates(true)
filteredAppList.forEach { app ->
add(
AppListViewModel_()
.id(app.id)
.app(app)
.click(View.OnClickListener {
binding.searchBar.hideKeyboard()
openDetailsFragment(app.packageName, app)
})
)
}
if (searchBundle.subBundles.isNotEmpty()) {
add(
AppProgressViewModel_()
.id("progress")
)
} }
} }
binding.recycler.adapter?.let { recycler.setController(controller)
if (it.itemCount < 10) { recycler.addOnScrollListener(scrollListener)
viewModel.next(searchBundle.subBundles) }
with(viewModel) {
search(query)
liveData.observe(viewLifecycleOwner) {
when (it) {
is ViewState.Loading -> {
controller.setData(null)
}
is ViewState.Success<*> -> {
val stash = it.getDataAs<AppStreamStash>()
controller.setData(stash[query])
}
else -> {}
} }
} }
} }
} }
private fun attachSearch() { private fun attachSearch() {
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
binding.searchBar.addTextChangedListener(object : TextWatcher { binding.searchBar.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
@@ -230,44 +128,35 @@ class SearchResultsFragment : BaseFragment<FragmentSearchResultBinding>(),
|| actionId == KeyEvent.ACTION_DOWN || actionId == KeyEvent.ACTION_DOWN
|| actionId == KeyEvent.KEYCODE_ENTER || actionId == KeyEvent.KEYCODE_ENTER
) { ) {
query = binding.searchBar.text.toString() query = binding.searchBar.text.toString()
query?.let {
requireArguments().putString("query", it) queryViewModel(query)
queryViewModel(it)
return@setOnEditorActionListener true return@setOnEditorActionListener true
}
} }
false false
} }
} }
private fun updateQuery(query: String) {
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
binding.searchBar.setSelection(query.length)
queryViewModel(query)
}
private fun queryViewModel(query: String) { private fun queryViewModel(query: String) {
updateController(null) scrollListener.resetPageCount()
viewModel.observeSearchResults(query) viewModel.search(query)
} }
private fun filter(appList: List<App>): List<App> { override fun onHeaderClicked(streamCluster: StreamCluster) {
val filter = viewModel.filterProvider.getSavedFilter() openStreamBrowseFragment(streamCluster)
return appList
.asSequence()
.filter { app ->
app.displayName.isNotEmpty() &&
(filter.paidApps || app.isFree) &&
(filter.appsWithAds || !app.containsAds) &&
(filter.gsfDependentApps || app.dependencies.dependentPackages.isEmpty()) &&
(filter.rating <= 0 || app.rating.average >= filter.rating) &&
(filter.downloads <= 0 || app.installs >= filter.downloads)
}
.toList()
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onClusterScrolled(streamCluster: StreamCluster) {
if (key == PREFERENCE_FILTER) query?.let { queryViewModel(it) } viewModel.observeCluster(query, streamCluster)
}
override fun onAppClick(app: App) {
openDetailsFragment(app.packageName, app)
}
override fun onAppLongClick(app: App) {
} }
} }

View File

@@ -23,21 +23,23 @@ import android.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.gplayapi.data.models.SearchBundle import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.SearchHelper import com.aurora.gplayapi.helpers.SearchHelper
import com.aurora.gplayapi.helpers.contracts.SearchContract import com.aurora.gplayapi.helpers.contracts.SearchContract
import com.aurora.gplayapi.helpers.web.WebSearchHelper import com.aurora.gplayapi.helpers.web.WebSearchHelper
import com.aurora.store.AppStreamStash
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.providers.FilterProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SearchResultViewModel @Inject constructor( class SearchResultViewModel @Inject constructor(
val filterProvider: FilterProvider,
private val authProvider: AuthProvider, private val authProvider: AuthProvider,
private val searchHelper: SearchHelper, private val searchHelper: SearchHelper,
private val webSearchHelper: WebSearchHelper private val webSearchHelper: WebSearchHelper
@@ -45,50 +47,146 @@ class SearchResultViewModel @Inject constructor(
private val TAG = SearchResultViewModel::class.java.simpleName private val TAG = SearchResultViewModel::class.java.simpleName
val liveData: MutableLiveData<SearchBundle> = MutableLiveData() val liveData: MutableLiveData<ViewState> = MutableLiveData()
private var searchBundle: SearchBundle = SearchBundle() private val stash: AppStreamStash = mutableMapOf()
private val helper: SearchContract private val contract: SearchContract
get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper get() = if (authProvider.isAnonymous) webSearchHelper else searchHelper
fun observeSearchResults(query: String) { private val stashMutex = Mutex()
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
searchBundle = search(query)
liveData.postValue(searchBundle)
} catch (e: Exception) {
}
}
}
}
private fun search(query: String): SearchBundle {
return helper.searchResults(query)
}
@Synchronized @Synchronized
fun next(nextSubBundleSet: Set<SearchBundle.SubBundle>) { fun search(query: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
supervisorScope { try {
try { stashMutex.withLock {
if (nextSubBundleSet.isNotEmpty()) { liveData.postValue(ViewState.Loading)
val newSearchBundle = helper.next(nextSubBundleSet.toMutableSet())
if (newSearchBundle.appList.isNotEmpty()) {
searchBundle = searchBundle.copy(
subBundles = newSearchBundle.subBundles,
appList = searchBundle.appList + newSearchBundle.appList
)
liveData.postValue(searchBundle) var bundle = targetBundle(query)
}
// Post existing data if any clusters exist
if (bundle.hasCluster()) {
liveData.postValue(ViewState.Success(stash.toMap()))
return@launch
} }
} catch (e: Exception) {
Log.d(TAG, "Failed to get next bundle", e) // Fetch new stream bundle
val newBundle = contract.searchResults(query)
bundle = bundle.copy(
streamClusters = newBundle.streamClusters,
streamNextPageUrl = newBundle.streamNextPageUrl
)
stash[query] = bundle
liveData.postValue(ViewState.Success(stash.toMap()))
} }
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
} }
} }
} }
fun observe(query: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
stashMutex.withLock {
var bundle = targetBundle(query)
if (bundle.hasNext()) {
val newBundle = contract.nextStreamBundle(
query,
bundle.streamNextPageUrl
)
// Update old bundle
bundle = bundle.copy(
streamClusters = bundle.streamClusters + newBundle.streamClusters,
streamNextPageUrl = newBundle.streamNextPageUrl
)
stash[query] = bundle
liveData.postValue(ViewState.Success(stash.toMap()))
} else {
Log.i(TAG, "End of Bundle")
// If stream ends, likely there are clusters that need to be processed
bundle.streamClusters.values.forEach {
if (it.clusterNextPageUrl.isEmpty()) {
postClusterEnd(query, it.id)
}
// Empty title or query as title indicates main stream cluster
if (it.clusterTitle.isEmpty() or (it.clusterTitle == bundle.streamTitle)) {
observeCluster(query, it)
}
}
}
}
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
}
}
}
fun observeCluster(query: String, streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) {
try {
if (streamCluster.hasNext()) {
val newCluster = contract.nextStreamCluster(
query,
streamCluster.clusterNextPageUrl
)
stashMutex.withLock {
updateCluster(query, streamCluster.id, newCluster)
}
liveData.postValue(ViewState.Success(stash.toMap()))
} else {
stashMutex.withLock {
postClusterEnd(query, streamCluster.id)
}
liveData.postValue(ViewState.Success(stash.toMap()))
}
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
}
}
}
private fun updateCluster(query: String, clusterID: Int, newCluster: StreamCluster) {
val bundle = stash[query] ?: return
val oldCluster = bundle.streamClusters[clusterID] ?: return
val mergedCluster = oldCluster.copy(
clusterNextPageUrl = newCluster.clusterNextPageUrl,
clusterAppList = oldCluster.clusterAppList + newCluster.clusterAppList
)
val updatedClusters = bundle.streamClusters.toMutableMap().apply {
this[clusterID] = mergedCluster
}
stash[query] = bundle.copy(streamClusters = updatedClusters)
}
private fun postClusterEnd(query: String, clusterID: Int) {
val bundle = stash[query] ?: return
val oldCluster = bundle.streamClusters[clusterID] ?: return
val updatedCluster = oldCluster.copy(clusterNextPageUrl = "")
val updatedClusters = bundle.streamClusters.toMutableMap().apply {
this[clusterID] = updatedCluster
}
stash[query] = bundle.copy(streamClusters = updatedClusters)
}
private fun targetBundle(query: String): StreamBundle {
return stash.getOrPut(query.trim()) { StreamBundle(streamTitle = query) }
}
} }

View File

@@ -17,7 +17,7 @@
~ ~
--> -->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator" android:id="@+id/coordinator"
@@ -25,55 +25,40 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".view.ui.search.SearchResultsFragment"> tools:context=".view.ui.search.SearchResultsFragment">
<RelativeLayout <androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
app:menu="@menu/menu_search"
app:navigationIcon="@drawable/ic_arrow_back">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchBar"
android:layout_width="match_parent"
android:layout_height="42dp"
android:background="@null"
android:hint="@string/search_hint"
android:imeOptions="flagNoExtractUi|actionSearch"
android:inputType="text"
android:paddingStart="@dimen/padding_large"
android:paddingEnd="@dimen/padding_normal"
android:singleLine="true" />
</androidx.appcompat.widget.Toolbar>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/toolbar" />
<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:layout_below="@+id/divider"
android:clipToPadding="true"
<androidx.appcompat.widget.Toolbar android:paddingTop="@dimen/padding_normal"
android:id="@+id/toolbar" android:paddingBottom="@dimen/height_bottom_adj"
android:layout_width="match_parent" tools:listitem="@layout/view_app_list" />
android:layout_height="wrap_content" </RelativeLayout>
android:minHeight="?attr/actionBarSize"
app:menu="@menu/menu_search"
app:navigationIcon="@drawable/ic_arrow_back">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchBar"
android:layout_width="match_parent"
android:layout_height="42dp"
android:background="@null"
android:hint="@string/search_hint"
android:imeOptions="flagNoExtractUi|actionSearch"
android:inputType="text"
android:paddingStart="@dimen/padding_large"
android:paddingEnd="@dimen/padding_normal"
android:singleLine="true" />
</androidx.appcompat.widget.Toolbar>
<com.google.android.material.divider.MaterialDivider
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/toolbar" />
<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/divider"
android:clipToPadding="true"
android:paddingTop="@dimen/padding_normal"
android:paddingBottom="@dimen/height_bottom_adj"
tools:listitem="@layout/view_app_list" />
</RelativeLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/filter_fab"
style="@style/Widget.Material3.ExtendedFloatingActionButton.Icon.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:text="@string/action_filter"
app:icon="@drawable/ic_filter" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -9,7 +9,7 @@ composeBom = "2025.05.01"
core = "1.16.0" core = "1.16.0"
epoxy = "5.1.4" epoxy = "5.1.4"
espresso = "3.6.1" espresso = "3.6.1"
gplayapi = "3.5.3" gplayapi = "3.5.5"
hiddenapibypass = "6.1" hiddenapibypass = "6.1"
hilt = "2.56.2" hilt = "2.56.2"
hiltWork = "1.2.0" hiltWork = "1.2.0"