From b507cd6d73011ae3476e2919c8d5e681bd33f1cf Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Sun, 1 Jun 2025 20:10:23 +0530 Subject: [PATCH] gplayapi:3.5.5 --- .../controller/SearchCarouselController.kt | 80 ++++++ .../store/view/ui/details/DevAppsFragment.kt | 98 ++++--- .../view/ui/search/SearchResultsFragment.kt | 255 +++++------------- .../viewmodel/search/SearchResultViewModel.kt | 172 +++++++++--- .../res/layout/fragment_search_result.xml | 87 +++--- gradle/libs.versions.toml | 2 +- 6 files changed, 381 insertions(+), 313 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/view/epoxy/controller/SearchCarouselController.kt diff --git a/app/src/main/java/com/aurora/store/view/epoxy/controller/SearchCarouselController.kt b/app/src/main/java/com/aurora/store/view/epoxy/controller/SearchCarouselController.kt new file mode 100644 index 000000000..2662edcf7 --- /dev/null +++ b/app/src/main/java/com/aurora/store/view/epoxy/controller/SearchCarouselController.kt @@ -0,0 +1,80 @@ +/* + * Aurora Store + * Copyright (C) 2021, Rahul Kumar Patel + * + * 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 . + * + */ + +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() { + + 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") + ) + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/view/ui/details/DevAppsFragment.kt b/app/src/main/java/com/aurora/store/view/ui/details/DevAppsFragment.kt index 2de10d2c9..5c206dc7d 100644 --- a/app/src/main/java/com/aurora/store/view/ui/details/DevAppsFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/details/DevAppsFragment.kt @@ -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() { +class DevAppsFragment : BaseFragment(), + 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() + 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) { + } } diff --git a/app/src/main/java/com/aurora/store/view/ui/search/SearchResultsFragment.kt b/app/src/main/java/com/aurora/store/view/ui/search/SearchResultsFragment.kt index 118966b7e..a4992c218 100644 --- a/app/src/main/java/com/aurora/store/view/ui/search/SearchResultsFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/search/SearchResultsFragment.kt @@ -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(), - 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 { - 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() + 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(), || 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): List { - 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) { + } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/search/SearchResultViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/search/SearchResultViewModel.kt index ae2ef978f..fdc1d3a49 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/search/SearchResultViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/search/SearchResultViewModel.kt @@ -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 = MutableLiveData() + val liveData: MutableLiveData = 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) { + 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) } + } } diff --git a/app/src/main/res/layout/fragment_search_result.xml b/app/src/main/res/layout/fragment_search_result.xml index 8935bd3ed..e636220a1 100644 --- a/app/src/main/res/layout/fragment_search_result.xml +++ b/app/src/main/res/layout/fragment_search_result.xml @@ -17,7 +17,7 @@ ~ --> - - + + + + + + + - - - - - - - - - - - - - + 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" /> + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76d4a5e4d..e9b1e0b12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"