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

Commit 17aee9c0 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal Committed by Jonathan Klee
Browse files

fix(home): reduce Play top chart fanout

parent 8da38bea
Loading
Loading
Loading
Loading
Loading
+187 −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.playstore

import android.content.Context
import com.aurora.gplayapi.helpers.TopChartsHelper
import com.aurora.gplayapi.helpers.contracts.TopChartsContract.Chart
import com.aurora.gplayapi.helpers.contracts.TopChartsContract.Type
import com.aurora.gplayapi.helpers.web.WebTopChartsHelper
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.utils.toApplication
import foundation.e.apps.data.login.api.PlayStoreAuthManager
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.domain.auth.AuthSession
import foundation.e.apps.domain.auth.AuthSessionRepository
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import com.aurora.gplayapi.data.models.App as GplayApp

class HomeTopChartsProvider @Inject constructor(
    @ApplicationContext private val context: Context,
    private val gPlayHttpClient: GPlayHttpClient,
    private val playStoreAuthManager: PlayStoreAuthManager,
    private val authSessionRepository: AuthSessionRepository,
    private val playStoreRequestExecutor: PlayStoreRequestExecutor,
) {
    suspend fun getTopChartSections(): List<HomeTopChartSection> = coroutineScope {
        val topChartSource = resolveTopChartSource()
        val topChartRequestLimiter = Semaphore(permits = TOP_CHART_REQUEST_CONCURRENCY)
        createTopChartElements().map { homeElement ->
            async {
                topChartRequestLimiter.withPermit {
                    val apps = getTopAppCluster(homeElement, topChartSource)
                    if (apps.isEmpty()) {
                        null
                    } else {
                        HomeTopChartSection(
                            title = homeElement.title,
                            apps = apps.map { it.toApplication(context) },
                        )
                    }
                }
            }
        }.awaitAll().filterNotNull()
    }

    private fun createTopChartElements() = listOf(
        HomeChartElement(
            context.getString(R.string.topselling_free_apps),
            Type.APPLICATION,
            Chart.TOP_SELLING_FREE,
        ),
        HomeChartElement(
            context.getString(R.string.topselling_free_games),
            Type.GAME,
            Chart.TOP_SELLING_FREE,
        ),
        HomeChartElement(
            context.getString(R.string.topgrossing_apps),
            Type.APPLICATION,
            Chart.TOP_GROSSING,
        ),
        HomeChartElement(
            context.getString(R.string.topgrossing_games),
            Type.GAME,
            Chart.TOP_GROSSING,
        ),
        HomeChartElement(
            context.getString(R.string.movers_shakers_apps),
            Type.APPLICATION,
            Chart.MOVERS_SHAKERS,
        ),
        HomeChartElement(
            context.getString(R.string.movers_shakers_games),
            Type.GAME,
            Chart.MOVERS_SHAKERS,
        ),
    )

    @Suppress("TooGenericExceptionCaught")
    private suspend fun getTopAppCluster(
        homeElement: HomeChartElement,
        topChartSource: TopChartSource,
    ): List<GplayApp> {
        return try {
            when (topChartSource) {
                TopChartSource.PLAY_API -> getTopAppClusterFromPlayApi(homeElement)
                TopChartSource.WEB -> getTopAppClusterFromWeb(homeElement)
            }
        } catch (exception: CancellationException) {
            throw exception
        } catch (exception: Exception) {
            // This is a best-effort per-chart boundary; web chart parsing can fail with many types.
            Timber.w(exception, "Could not get top apps for %s.", homeElement.title)
            emptyList()
        }
    }

    private suspend fun resolveTopChartSource(): TopChartSource {
        return when (val session = authSessionRepository.resolveCurrentSession()) {
            is AuthSession.PlayStoreSession -> when (session.loginMode) {
                PlayStoreLoginMode.ANONYMOUS -> TopChartSource.WEB
                PlayStoreLoginMode.GOOGLE,
                PlayStoreLoginMode.MICROG -> TopChartSource.PLAY_API
            }

            AuthSession.OpenSourceSession,
            AuthSession.Unauthenticated ->
                error("Play Store home top charts requested for unsupported session: $session")
        }
    }

    private suspend fun getTopAppClusterFromPlayApi(homeElement: HomeChartElement): List<GplayApp> {
        return withContext(Dispatchers.IO) {
            playStoreRequestExecutor.executeSearchWithAuthRecovery(
                operationName = "top apps",
                request = {
                    val topChartsHelper = TopChartsHelper(
                        playStoreAuthManager.requireValidatedPlayStoreAuth()
                    ).using(gPlayHttpClient)

                    topChartsHelper.getCluster(
                        homeElement.type.value,
                        homeElement.chart.value
                    ).clusterAppList
                }
            )
        }
    }

    private suspend fun getTopAppClusterFromWeb(homeElement: HomeChartElement): List<GplayApp> {
        return withContext(Dispatchers.IO) {
            val topChartsHelper = WebTopChartsHelper().using(gPlayHttpClient)
            topChartsHelper.getCluster(
                homeElement.type.value,
                homeElement.chart.value
            ).clusterAppList
        }
    }

    private data class HomeChartElement(
        val title: String,
        val type: Type,
        val chart: Chart,
    )

    private enum class TopChartSource {
        PLAY_API,
        WEB,
    }

    private companion object {
        const val TOP_CHART_REQUEST_CONCURRENCY = 3
    }
}

data class HomeTopChartSection(
    val title: String,
    val apps: List<Application>,
)
+18 −194
Original line number Diff line number Diff line
@@ -27,14 +27,10 @@ import com.aurora.gplayapi.exceptions.InternalException
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.ContentRatingHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.helpers.contracts.TopChartsContract.Chart
import com.aurora.gplayapi.helpers.contracts.TopChartsContract.Type
import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper
import com.aurora.gplayapi.helpers.web.WebCategoryHelper
import com.aurora.gplayapi.helpers.web.WebCategoryStreamHelper
import com.aurora.gplayapi.helpers.web.WebTopChartsHelper
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.R
import foundation.e.apps.data.StoreRepository
import foundation.e.apps.data.application.ApplicationDataManager
import foundation.e.apps.data.application.ApplicationRepository
@@ -48,17 +44,8 @@ import foundation.e.apps.data.handleNetworkResult
import foundation.e.apps.data.login.api.PlayStoreAuthManager
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.data.playstore.utils.gplayInternalExceptionHttpStatus
import foundation.e.apps.data.preference.PlayStoreAuthStore
import foundation.e.apps.data.system.SystemInfoProvider
import foundation.e.apps.domain.auth.AuthResult
import foundation.e.apps.domain.auth.TokenRefreshHandler
import foundation.e.apps.domain.auth.describe
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.HttpURLConnection
@@ -70,55 +57,33 @@ class PlayStoreRepository @Inject constructor(
    @ApplicationContext private val context: Context,
    private val gPlayHttpClient: GPlayHttpClient,
    private val playStoreAuthManager: PlayStoreAuthManager,
    private val playStoreAuthStore: PlayStoreAuthStore,
    private val tokenRefreshHandler: TokenRefreshHandler,
    private val playStoreRequestExecutor: PlayStoreRequestExecutor,
    private val homeTopChartsProvider: HomeTopChartsProvider,
    private val applicationDataManager: ApplicationDataManager,
    private val playStoreSearchHelper: PlayStoreSearchHelper
) : StoreRepository {

    override suspend fun getHomeScreenData(list: MutableList<Home>) = coroutineScope {
        val homeElements = createTopChartElements()

        val results = homeElements.map { (title, chartMap) ->
            async {
                if (chartMap.isEmpty()) {
                    return@async null
                }
                val chart = chartMap.keys.iterator().next()
                val type = chartMap.values.iterator().next()
                title to getTopApps(type, chart)
            }
        }.awaitAll().filterNotNull()

        results.forEach { (title, apps) ->
            if (apps.isEmpty()) {
    override suspend fun getHomeScreenData(list: MutableList<Home>): List<Home> {
        homeTopChartsProvider.getTopChartSections().forEach { section ->
            if (section.apps.isEmpty()) {
                return@forEach
            }

            val fusedApps = apps.map { app ->
            val fusedApps = section.apps.map { app ->
                app.apply {
                    applicationDataManager.updateStatus(this)
                    applicationDataManager.updateFilterLevel(this)
                    source = Source.PLAY_STORE
                }
            }
            list.add(Home(title, fusedApps, ApplicationRepository.APP_TYPE_ANY))
            list.add(Home(section.title, fusedApps, ApplicationRepository.APP_TYPE_ANY))
        }

        list
        return list
    }

    private fun createTopChartElements() = mutableMapOf(
        context.getString(R.string.topselling_free_apps) to mapOf(Chart.TOP_SELLING_FREE to Type.APPLICATION),
        context.getString(R.string.topselling_free_games) to mapOf(Chart.TOP_SELLING_FREE to Type.GAME),
        context.getString(R.string.topgrossing_apps) to mapOf(Chart.TOP_GROSSING to Type.APPLICATION),
        context.getString(R.string.topgrossing_games) to mapOf(Chart.TOP_GROSSING to Type.GAME),
        context.getString(R.string.movers_shakers_apps) to mapOf(Chart.MOVERS_SHAKERS to Type.APPLICATION),
        context.getString(R.string.movers_shakers_games) to mapOf(Chart.MOVERS_SHAKERS to Type.GAME),
    )

    override suspend fun getSearchResults(pattern: String): List<Application> {
        val searchResult = executeSearchWithAuthRecovery(
        val searchResult = playStoreRequestExecutor.executeSearchWithAuthRecovery(
            operationName = "search results",
            request = { playStoreSearchHelper.getSearchResults(pattern) },
        )
@@ -127,7 +92,7 @@ class PlayStoreRepository @Inject constructor(
    }

    override suspend fun getSearchSuggestions(pattern: String): List<SearchSuggestion> {
        return executeSearchWithAuthRecovery(
        return playStoreRequestExecutor.executeSearchWithAuthRecovery(
            operationName = "search suggestions",
            request = { playStoreSearchHelper.getSearchSuggestions(pattern) },
        )
@@ -166,7 +131,7 @@ class PlayStoreRepository @Inject constructor(
    // batch request response might not contain images and descriptions of the app
    suspend fun getAppsDetails(packageNames: List<String>): List<Application> =
        withContext(Dispatchers.IO) {
            val appDetails = executeWithPlayAuthRecovery(
            val appDetails = playStoreRequestExecutor.executeWithPlayAuthRecovery(
                operationName = "app details list",
                request = { getAppDetailsHelper().getAppByPackageName(packageNames) },
            )
@@ -181,7 +146,7 @@ class PlayStoreRepository @Inject constructor(

    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            val appDetails = executeWithPlayAuthRecovery(
            val appDetails = playStoreRequestExecutor.executeWithPlayAuthRecovery(
                operationName = "app details",
                request = { getAppDetailsOrThrowNotFound(packageName) },
            )
@@ -208,118 +173,6 @@ class PlayStoreRepository @Inject constructor(
        }
    }

    private suspend fun <T> executeWithPlayAuthRecovery(
        operationName: String,
        request: suspend () -> T,
    ): T {
        return try {
            request()
        } catch (exception: CancellationException) {
            throw exception
        } catch (exception: GplayHttpRequestException) {
            recoverFromPlayRequestFailure(
                operationName = operationName,
                status = exception.status,
                exception = exception,
                request = request
            )
        } catch (exception: InternalException) {
            recoverFromPlayRequestFailure(
                operationName = operationName,
                status = exception.gplayInternalExceptionHttpStatus(),
                exception = exception,
                request = request
            )
        }
    }

    private suspend fun <T> recoverFromPlayRequestFailure(
        operationName: String,
        status: Int?,
        exception: Exception,
        request: suspend () -> T,
    ): T {
        if (!shouldRetryPlayRequest(status)) {
            throw exception
        }

        return retryAfterSuccessfulPlayAuthRefreshOrThrow(
            operationName = operationName,
            reason = "request failed with status $status",
            originalException = exception,
            invalidateBootstrapTokens = status == HttpURLConnection.HTTP_UNAUTHORIZED,
            request = request,
        )
    }

    private suspend fun <T> executeSearchWithAuthRecovery(
        operationName: String,
        request: suspend () -> List<T>,
    ): List<T> {
        return try {
            executeWithPlayAuthRecovery(
                operationName = operationName,
                request = request,
            )
        } catch (exception: CancellationException) {
            throw exception
        } catch (exception: GplayHttpRequestException) {
            Timber.w(exception, "Couldn't fetch %s.", operationName)
            emptyList()
        } catch (exception: InternalException) {
            Timber.w(exception, "Couldn't fetch %s.", operationName)
            emptyList()
        }
    }

    private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrNull(
        operationName: String,
        reason: String,
        request: suspend () -> T,
    ): T? {
        Timber.i("Retrying %s after refreshing Play auth because %s", operationName, reason)

        return when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) {
            is AuthResult.Success -> request()
            is AuthResult.Failure -> {
                Timber.w(
                    "Skipping %s retry because Play auth refresh failed: %s",
                    operationName,
                    refreshResult.error.describe(),
                )
                null
            }
        }
    }

    private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrThrow(
        operationName: String,
        reason: String,
        originalException: Exception,
        invalidateBootstrapTokens: Boolean = false,
        request: suspend () -> T,
    ): T {
        Timber.i("Retrying %s after refreshing Play auth because %s", operationName, reason)
        if (invalidateBootstrapTokens) {
            // 401 means cached Play auth is definitively rejected. Evict the stored AAS token
            // too, so Google-mode refresh does not silently recreate auth from the same stale
            // bootstrap credential.
            playStoreAuthStore.saveAasToken("")
        }

        when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) {
            is AuthResult.Success -> return request()
            is AuthResult.Failure -> {
                Timber.w(
                    "Skipping %s retry because Play auth refresh failed: %s",
                    operationName,
                    refreshResult.error.describe(),
                )
                throw originalException
            }
        }
    }

    private fun GplayHttpRequestException.toAppDetailsLookupFailure(): Exception {
        return if (status == HttpURLConnection.HTTP_NOT_FOUND) {
            InternalException.AppNotFound()
@@ -328,15 +181,6 @@ class PlayStoreRepository @Inject constructor(
        }
    }

    private fun shouldRetryPlayRequest(status: Int?): Boolean {
        return when (status) {
            null,
            HttpURLConnection.HTTP_NOT_FOUND,
            GPlayHttpClient.STATUS_CODE_TOO_MANY_REQUESTS -> false
            else -> true
        }
    }

    private suspend fun retryBatchAppDetailsWhenVersionCodesAreZero(
        appDetails: List<GplayApp>,
        request: suspend () -> List<GplayApp>,
@@ -346,7 +190,7 @@ class PlayStoreRepository @Inject constructor(
        }

        Timber.i("Version code is 0 for all app details.")
        val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull(
        val refreshedAppDetails = playStoreRequestExecutor.retryAfterSuccessfulPlayAuthRefreshOrNull(
            operationName = "app details list",
            reason = "all version codes are 0",
            request = request,
@@ -368,7 +212,7 @@ class PlayStoreRepository @Inject constructor(
        }

        Timber.i("Version code is 0 for %s.", appDetails.packageName)
        val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull(
        val refreshedAppDetails = playStoreRequestExecutor.retryAfterSuccessfulPlayAuthRefreshOrNull(
            operationName = "app details",
            reason = "version code is 0 for ${appDetails.packageName}",
            request = request,
@@ -406,26 +250,6 @@ class PlayStoreRepository @Inject constructor(
        }
    }

    private suspend fun getTopApps(
        type: Type,
        chart: Chart
    ): List<Application> {
        val topApps = mutableListOf<GplayApp>()
        withContext(Dispatchers.IO) {
            val topChartsHelper = WebTopChartsHelper().using(gPlayHttpClient)
            try {
                topApps.addAll(topChartsHelper.getCluster(type.value, chart.value).clusterAppList)
            } catch (exception: Exception) {
                Timber.w("Could not get top apps: $exception")
                topApps.addAll(emptyList())
            }
        }

        return topApps.map {
            it.toApplication(context)
        }
    }

    suspend fun getDownloadInfo(
        idOrPackageName: String,
        versionCode: Long,
@@ -444,7 +268,7 @@ class PlayStoreRepository @Inject constructor(
            throw IllegalStateException("Could not get download details for $idOrPackageName")
        }

        executeWithPlayAuthRecovery(
        playStoreRequestExecutor.executeWithPlayAuthRecovery(
            operationName = "download info",
            request = {
                // Don't store auth data in a variable. Always get GPlay auth from repository,
@@ -464,7 +288,7 @@ class PlayStoreRepository @Inject constructor(
        offerType: Int
    ): List<PlayFile> {
        return withContext(Dispatchers.IO) {
            executeWithPlayAuthRecovery(
            playStoreRequestExecutor.executeWithPlayAuthRecovery(
                operationName = "on-demand module download",
                request = {
                    val purchaseHelper = PurchaseHelper(playStoreAuthManager.requireValidatedPlayStoreAuth())
@@ -480,7 +304,7 @@ class PlayStoreRepository @Inject constructor(
        contentRating: ContentRating
    ): ContentRating {
        return withContext(Dispatchers.IO) {
            executeWithPlayAuthRecovery(
            playStoreRequestExecutor.executeWithPlayAuthRecovery(
                operationName = "content rating",
                request = {
                    val contentRatingHelper =
@@ -496,7 +320,7 @@ class PlayStoreRepository @Inject constructor(

    suspend fun getEnglishContentRating(packageName: String): ContentRating {
        return withContext(Dispatchers.IO) {
            executeWithPlayAuthRecovery(
            playStoreRequestExecutor.executeWithPlayAuthRecovery(
                operationName = "english content rating",
                request = {
                    val contentRatingHelper =
+158 −0

File added.

Preview size limit exceeded, changes collapsed.

+122 −3

File changed.

Preview size limit exceeded, changes collapsed.