Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 7fdce1f5 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

feat: implement pagination for PlayStore search

- Add `PlayStorePagingSource` and `PlayStorePagingRepository` to handle infinite scrolling for PlayStore apps

- Integrate Play Store paging flow into `SearchViewModelV2` and `SearchResultsContent`

- Create `PagingPlayStoreResultList` composable for displaying Play Store items

- Centralize icon URL generation in `Application` model and refactor adapters to use it

- Introduce `searchVersion` to properly reset scroll state on new search queries
parent 221c6dd2
Loading
Loading
Loading
Loading
+18 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import androidx.core.net.toUri
import com.aurora.gplayapi.Constants.Restriction
import com.aurora.gplayapi.data.models.ContentRating
import com.google.gson.annotations.SerializedName
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
import foundation.e.apps.data.enums.FilterLevel
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
@@ -99,6 +100,23 @@ data class Application(
    val antiFeatures: List<Map<String, String>> = emptyList(),
    var isSystemApp: Boolean = false,
) {
    val iconUrl: String?
        get() {
            if (icon_image_path.isBlank()) {
                return null
            }
            return when (source) {
                Source.OPEN_SOURCE, Source.PWA -> {
                    if (icon_image_path.startsWith("http")) {
                        icon_image_path
                    } else {
                        CleanApkRetrofit.ASSET_URL + icon_image_path
                    }
                }
                Source.SYSTEM_APP, Source.PLAY_STORE -> icon_image_path
            }
        }

    fun updateType() {
        this.type = if (this.is_pwa) PWA else NATIVE
    }
+1 −3
Original line number Diff line number Diff line
@@ -56,13 +56,11 @@ class CleanApkSearchPagingSource(
            )

            val totalPages = response.pages
            val data = response.apps

            val nextKey = if (page < totalPages) page + 1 else null
            val prevKey = if (page > INITIAL_PAGE) page - 1 else null

            LoadResult.Page(
                data = data,
                data = response.apps,
                prevKey = prevKey,
                nextKey = nextKey
            )
+50 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.search

import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.aurora.gplayapi.data.models.App
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class PlayStorePagingRepository @Inject constructor(
    private val gPlayHttpClient: GPlayHttpClient,
) {

    fun playStoreSearch(query: String, pageSize: Int): Flow<PagingData<App>> {
        return Pager(
            config = PagingConfig(
                pageSize = pageSize,
                enablePlaceholders = false,
                prefetchDistance = 2
            ),
            pagingSourceFactory = {
                PlayStorePagingSource(
                    query = query,
                    gPlayHttpClient = gPlayHttpClient,
                )
            }
        ).flow
    }
}
+141 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.search

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.web.WebSearchHelper
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException

private const val INITIAL_PAGE = 1

class PlayStorePagingSource(
    private val query: String,
    private val gPlayHttpClient: GPlayHttpClient,
) : PagingSource<Int, App>() {

    private val webSearchHelper = WebSearchHelper().using(gPlayHttpClient)

    private var nextBundleUrl: String? = null
    private val nextStreamUrls = mutableSetOf<String>()

    override fun getRefreshKey(state: PagingState<Int, App>): Int? {
        val anchor = state.anchorPosition
        val anchorPage = anchor?.let { state.closestPageToPosition(it) }
        val prev = anchorPage?.prevKey
        val next = anchorPage?.nextKey
        return when {
            prev != null -> prev + 1
            next != null -> next - 1
            else -> null
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, App> {
        val page = params.key ?: INITIAL_PAGE
        return try {
            val data: List<App> = when (page) {
                INITIAL_PAGE -> loadFirstPage()
                else -> loadNextPage()
            }

            val nextKey = if (data.isEmpty()) {
                null
            } else {
                page + 1
            }
            val prevKey = if (page == INITIAL_PAGE) {
                null
            } else {
                page - 1
            }

            val loadResult = LoadResult.Page(
                data = data,
                prevKey = prevKey,
                nextKey = nextKey
            )
            loadResult
        } catch (exception: GplayHttpRequestException) {
            LoadResult.Error(exception)
        } catch (exception: IOException) {
            LoadResult.Error(exception)
        } catch (exception: IllegalStateException) {
            LoadResult.Error(exception)
        }
    }

    private suspend fun loadFirstPage(): List<App> = withContext(Dispatchers.IO) {
        val bundle = webSearchHelper.searchResults(query)
        nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() }

        if (!bundle.hasCluster()) {
            return@withContext emptyList<App>()
        }

        bundle.streamClusters.values.collectApplications()
    }

    private suspend fun loadNextPage(): List<App> = withContext(Dispatchers.IO) {
        return@withContext when {
            nextStreamUrls.isNotEmpty() -> {
                val pendingStreamUrls = nextStreamUrls.toList()
                nextStreamUrls.clear()

                pendingStreamUrls.flatMap { streamUrl ->
                    val cluster = webSearchHelper.nextStreamCluster(query, streamUrl)
                    listOf(cluster).collectApplications()
                }
            }

            !nextBundleUrl.isNullOrBlank() -> {
                val bundle = webSearchHelper.nextStreamBundle(query, nextBundleUrl!!)
                nextBundleUrl = bundle.streamNextPageUrl.takeIf { it.isNotBlank() }

                bundle.streamClusters.values.collectApplications()
            }

            else -> emptyList()
        }
    }

    private fun Collection<StreamCluster>.collectApplications(): List<App> {
        val apps = mutableListOf<App>()

        this.forEach { cluster ->
            if (cluster.hasNext()) {
                nextStreamUrls.add(cluster.clusterNextPageUrl)
            } else {
                // Intentionally no action when there is no next stream page; absence means end of this cluster.
            }

            apps.addAll(cluster.clusterAppList)
        }

        // Deduplicate by package name to avoid duplicate rows when the API returns overlapping clusters.
        return apps
            .distinctBy { app -> app.packageName }
    }
}
+15 −0
Original line number Diff line number Diff line
@@ -20,9 +20,12 @@ package foundation.e.apps.di

import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.search.CleanApkSearchPagingRepository
import foundation.e.apps.data.search.PlayStorePagingRepository
import foundation.e.apps.data.search.SearchPagingRepository
import javax.inject.Singleton

@@ -34,4 +37,16 @@ abstract class SearchPagingModule {
    abstract fun bindSearchPagingRepository(
        impl: CleanApkSearchPagingRepository
    ): SearchPagingRepository

    companion object {
        @Provides
        @Singleton
        fun providePlayStorePagingRepository(
            gPlayHttpClient: GPlayHttpClient
        ): PlayStorePagingRepository {
            return PlayStorePagingRepository(
                gPlayHttpClient = gPlayHttpClient
            )
        }
    }
}
Loading