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.navigation.fragment.findNavController
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.view.custom.recycler.EndlessRecyclerOnScrollListener
import com.aurora.store.view.epoxy.views.AppProgressViewModel_
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
import com.aurora.store.view.epoxy.controller.GenericCarouselController
import com.aurora.store.view.epoxy.controller.SearchCarouselController
import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.search.SearchResultViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>() {
class DevAppsFragment : BaseFragment<FragmentGenericWithToolbarBinding>(),
GenericCarouselController.Callbacks {
private val args: DevAppsFragmentArgs by navArgs()
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?) {
super.onViewCreated(view, savedInstanceState)
viewModel.liveData.observe(viewLifecycleOwner) {
updateController(it)
}
// 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) }
with(binding) {
toolbar.apply {
title = args.developerName
setNavigationOnClickListener { findNavController().navigateUp() }
}
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) {
binding.recycler
.withModels {
setFilterDuplicates(true)
searchBundle.appList
.filter { it.displayName.isNotEmpty() }
.forEach { app ->
add(
AppListViewModel_()
.id(app.id)
.app(app)
.click(View.OnClickListener {
openDetailsFragment(app.packageName, app)
})
)
liveData.observe(viewLifecycleOwner) {
when (it) {
is ViewState.Loading -> {
controller.setData(null)
}
if (searchBundle.subBundles.isNotEmpty()) {
add(
AppProgressViewModel_()
.id("progress")
)
is ViewState.Success<*> -> {
val stash = it.getDataAs<AppStreamStash>()
controller.setData(stash[query])
}
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
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.viewModels
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.aurora.extensions.hideKeyboard
import com.aurora.extensions.showKeyboard
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.data.model.Filter
import com.aurora.store.data.providers.FilterProvider.Companion.PREFERENCE_FILTER
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.model.ViewState.Loading.getDataAs
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.epoxy.views.AppProgressViewModel_
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_
import com.aurora.store.view.epoxy.controller.GenericCarouselController
import com.aurora.store.view.epoxy.controller.SearchCarouselController
import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.search.SearchResultViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
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 searchBundle: SearchBundle = SearchBundle()
private var shimmerAnimationVisible = false
private var scrollListener: EndlessRecyclerOnScrollListener =
object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) {
override fun onLoadMore(currentPage: Int) {
viewModel.observe(query)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
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
attachSearch()
// RecyclerView
val endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() {
override fun onLoadMore(currentPage: Int) {
viewModel.next(searchBundle.subBundles)
}
}
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))
with(binding) {
toolbar.apply {
setNavigationOnClickListener {
binding.searchBar.hideKeyboard()
findNavController().navigateUp()
}
}
return
}
val filteredAppList = filter(searchBundle.appList)
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)
)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_clear -> {
binding.searchBar.text?.clear()
binding.searchBar.showKeyboard()
}
R.id.action_download -> findNavController().navigate(R.id.downloadFragment)
}
}
}
} 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")
)
true
}
}
binding.recycler.adapter?.let {
if (it.itemCount < 10) {
viewModel.next(searchBundle.subBundles)
recycler.setController(controller)
recycler.addOnScrollListener(scrollListener)
}
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() {
binding.searchBar.text = Editable.Factory.getInstance().newEditable(query)
binding.searchBar.addTextChangedListener(object : TextWatcher {
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.KEYCODE_ENTER
) {
query = binding.searchBar.text.toString()
query?.let {
requireArguments().putString("query", it)
queryViewModel(it)
return@setOnEditorActionListener true
}
queryViewModel(query)
return@setOnEditorActionListener true
}
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) {
updateController(null)
viewModel.observeSearchResults(query)
scrollListener.resetPageCount()
viewModel.search(query)
}
private fun filter(appList: List<App>): List<App> {
val filter = viewModel.filterProvider.getSavedFilter()
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 onHeaderClicked(streamCluster: StreamCluster) {
openStreamBrowseFragment(streamCluster)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == PREFERENCE_FILTER) query?.let { queryViewModel(it) }
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

@@ -23,21 +23,23 @@ import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
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.contracts.SearchContract
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.FilterProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@HiltViewModel
class SearchResultViewModel @Inject constructor(
val filterProvider: FilterProvider,
private val authProvider: AuthProvider,
private val searchHelper: SearchHelper,
private val webSearchHelper: WebSearchHelper
@@ -45,50 +47,146 @@ class SearchResultViewModel @Inject constructor(
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
fun observeSearchResults(query: String) {
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)
}
private val stashMutex = Mutex()
@Synchronized
fun next(nextSubBundleSet: Set<SearchBundle.SubBundle>) {
fun search(query: String) {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
if (nextSubBundleSet.isNotEmpty()) {
val newSearchBundle = helper.next(nextSubBundleSet.toMutableSet())
if (newSearchBundle.appList.isNotEmpty()) {
searchBundle = searchBundle.copy(
subBundles = newSearchBundle.subBundles,
appList = searchBundle.appList + newSearchBundle.appList
)
try {
stashMutex.withLock {
liveData.postValue(ViewState.Loading)
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:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator"
@@ -25,55 +25,40 @@
android:layout_height="match_parent"
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_height="match_parent"
android:orientation="vertical">
<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_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>
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>

View File

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