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

Commit 507fd2bf authored by Guillaume Jacquart's avatar Guillaume Jacquart
Browse files

feat:3871: Unswallow exception of GPlayAPI requests.

parent 69045348
Loading
Loading
Loading
Loading
Loading
+47 −5
Original line number Diff line number Diff line
@@ -50,15 +50,23 @@ import foundation.e.apps.data.handleNetworkResult
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.di.qualifiers.IoCoroutineScope
import foundation.e.apps.utils.SystemInfoProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.util.Properties
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import com.aurora.gplayapi.data.models.App as GplayApp

@Suppress("TooManyFunctions", "LongParameterList")
@@ -72,7 +80,12 @@ class PlayStoreRepository @Inject constructor(
    private val googleLoginDataSource: GoogleLoginDataSource,
    private val nativeDeviceProperty: Properties,
    private val json: Json,
    @IoCoroutineScope private val ioCoroutineScope: CoroutineScope
) : StoreRepository {
    companion object {
        private val gPlayHttpClientPukedExceptionWaitDuration: Duration = 10.milliseconds
    }

    suspend fun updateToken(): AuthData {
        val userType = appLoungeDataStore.getUser()

@@ -211,7 +224,9 @@ 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) {
            var appDetails: List<GplayApp> = getAppDetailsHelper().getAppByPackageName(packageNames)
            var appDetails: List<GplayApp> = doAuthenticatedRequest { authData ->
                AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageNames)
            }

            if (!isEmulator() && appDetails.all { it.versionCode == 0L } && isAnonymousUser()) {
                // Google Play returns limited result ( i.e. version code being 0) with a stale token,
@@ -220,7 +235,10 @@ class PlayStoreRepository @Inject constructor(

                refreshPlayStoreAuthentication()

                appDetails = getAppDetailsHelper().getAppByPackageName(packageNames)
                appDetails = doAuthenticatedRequest { authData ->
                    AppDetailsHelper(authData).using(gPlayHttpClient)
                        .getAppByPackageName(packageNames)
                }

                if (appDetails.all { it.versionCode == 0L }) {
                    Timber.w("After refreshing auth, version code is still 0.")
@@ -233,7 +251,9 @@ class PlayStoreRepository @Inject constructor(

    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            var appDetails: GplayApp = getAppDetailsHelper().getAppByPackageName(packageName)
            var appDetails = doAuthenticatedRequest { authData ->
                AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName)
            }

            if (!isEmulator() && appDetails.versionCode == 0L && isAnonymousUser()) {
                // Google Play returns limited result ( i.e. version code being 0) with a stale token,
@@ -242,7 +262,9 @@ class PlayStoreRepository @Inject constructor(

                refreshPlayStoreAuthentication()

                appDetails = getAppDetailsHelper().getAppByPackageName(packageName)
                appDetails = doAuthenticatedRequest { authData ->
                    AppDetailsHelper(authData).using(gPlayHttpClient).getAppByPackageName(packageName)
                }

                if (appDetails.versionCode == 0L) {
                    Timber.w("After refreshing auth, version code is still 0. Giving up installation.")
@@ -384,8 +406,28 @@ class PlayStoreRepository @Inject constructor(
    }

    private suspend fun <T> doAuthenticatedRequest(request: suspend (AuthData) -> T): T {
        val lastErrorFlow = gPlayHttpClient.requestException.shareIn(
            scope = ioCoroutineScope,
            started = SharingStarted.Eagerly,
            replay = 1
        )

        val authData = appLoungeDataStore.getAuthData()
        return request(authData)
        return try {
            request(authData)
        } catch(e: Exception) {
            lastErrorFlow.timeout(gPlayHttpClientPukedExceptionWaitDuration)
                .catch {
                    Timber.d("No error emitted from GPlayHttpClient after timeout.")
                    throw e
                }.collect { pukedException ->
                    Timber.d(pukedException, "Error in GPlay request, Reswallow it for now.")
                    // Re-swallow the exception, until upper layer ar eready o receive new exceptions.
                    throw e
                }
            Timber.w("Un-swallow exception failed, reach un-reachable code")
            throw e
        }
    }

    // Helper function, to detect error not specific to the request
+76 −32
Original line number Diff line number Diff line
@@ -22,10 +22,18 @@ import androidx.annotation.VisibleForTesting
import com.aurora.gplayapi.data.models.PlayResponse
import com.aurora.gplayapi.network.IHttpClient
import foundation.e.apps.data.login.AuthObject
import foundation.e.apps.di.qualifiers.IoCoroutineScope
import foundation.e.apps.domain.entities.ConnectivityException
import foundation.e.apps.domain.entities.HttpFailedCodeException
import foundation.e.apps.domain.entities.HttpTooManyRequestException
import foundation.e.apps.domain.entities.HttpUnauthorizedException
import foundation.e.apps.utils.SystemInfoProvider
import foundation.e.apps.utils.eventBus.AppEvent
import foundation.e.apps.utils.eventBus.EventBus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -42,13 +50,17 @@ import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import java.io.IOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject

class GPlayHttpClient @Inject constructor(
    loggingInterceptor: HttpLoggingInterceptor
    loggingInterceptor: HttpLoggingInterceptor,
    @IoCoroutineScope private val ioCoroutineScope: CoroutineScope
) : IHttpClient {

    companion object {
@@ -69,6 +81,9 @@ class GPlayHttpClient @Inject constructor(
    override val responseCode: StateFlow<Int>
        get() = _responseCode.asStateFlow()

    private val _requestException = MutableSharedFlow<Exception>()
    val requestException: Flow<Exception> = _requestException

    @VisibleForTesting
    var okHttpClient = OkHttpClient().newBuilder()
        .retryOnConnectionFailure(false)
@@ -176,12 +191,30 @@ class GPlayHttpClient @Inject constructor(
        return try {
            val call = okHttpClient.newCall(request)
            response = call.execute()
            buildPlayResponse(response)
        } catch (e: GplayHttpRequestException) {
            throw e
            buildPlayResponse(response).apply {
                _responseCode.value = code
            }
        } catch (e: Exception) {
            val status = if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1
            throw GplayHttpRequestException(status, e.localizedMessage ?: "")
            val parsedException = when(e) {
                is SocketTimeoutException,
                is ConnectException,
                is UnknownHostException,
                is NoRouteToHostException -> ConnectivityException(cause = e)

                else -> e
            }

            ioCoroutineScope.launch {
                rawErrorResponse.emit(parsedException)
            }

            // Swallow the exception, until upper layer are upgraded.
            throw when(parsedException) {
                is HttpFailedCodeException -> GplayHttpRequestException(parsedException.statusCode, parsedException.message?: "HttpFailed exception ${parsedException.statusCode}")
                is ConnectivityException ->
                    GplayHttpRequestException(if (e is SocketTimeoutException) STATUS_CODE_TIMEOUT else -1, parsedException.message?: "")
                    else -> GplayHttpRequestException(-1, e.localizedMessage ?: "")
            }
        } finally {
            response?.close()
        }
@@ -199,9 +232,17 @@ class GPlayHttpClient @Inject constructor(
        val url = response.request.url
        val code = response.code
        val isSuccessful = response.isSuccessful
        val errorMessage = if (!isSuccessful) response.message else null
        val responseBytes = response.body?.bytes() ?: byteArrayOf()

        if (!isSuccessful) {
            val httpException = when(code) {
                STATUS_CODE_UNAUTHORIZED -> HttpUnauthorizedException(message = response.message)
                STATUS_CODE_TOO_MANY_REQUESTS -> HttpTooManyRequestException(message = response.message)
                else -> HttpFailedCodeException(statusCode = response.code, message = response.message)
            }


            // TODO: remove that
            when (code) {
                STATUS_CODE_UNAUTHORIZED -> MainScope().launch {
                    EventBus.invokeEvent(
@@ -220,25 +261,28 @@ class GPlayHttpClient @Inject constructor(
                }
            }

        if (!url.toString().contains(URL_SUBSTRING_PURCHASE) && code !in listOf(
            // TODO 20251211: limit this hack to purchase procedure.
            if (url.toString().contains(URL_SUBSTRING_PURCHASE) && code in listOf(
                    STATUS_CODE_OK,
                    STATUS_CODE_UNAUTHORIZED
                )
            ) {
            throw GplayHttpRequestException(code, response.message)
                return PlayResponse(
                    isSuccessful = false,
                    code = code,
                    responseBytes = responseBytes,
                    errorString = response.message
                )
            }

        fun PlayResponse.withErrorString(error: String?): PlayResponse {
            return if (error == null) return this else copy(errorString = error)
            throw httpException
        }

        return PlayResponse(
            isSuccessful = isSuccessful,
            code = code,
            responseBytes = responseBytes,
        ).withErrorString(errorMessage).apply {
            _responseCode.value = response.code
        }
            responseBytes = responseBytes
        )
    }
}

+9 −0
Original line number Diff line number Diff line
package foundation.e.apps.domain.entities

open class HttpFailedCodeException(val statusCode: Int = 520, message: String? = null, cause: Throwable? = null ): Exception(message?: "", cause)

class HttpUnauthorizedException(message: String? = null, cause: Throwable? = null, ): HttpFailedCodeException(statusCode = 401, message = message, cause = cause)

class HttpTooManyRequestException(message: String? = null, cause: Throwable? = null): HttpFailedCodeException(statusCode = 429, message = message, cause = cause)

class ConnectivityException(message: String? = null, cause: Throwable) : Exception(message?: cause.message, cause)