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

Commit a364c3dd authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

refactor(auth): clean playstore retry flow and typed anonymous failures

parent 209efb9e
Loading
Loading
Loading
Loading
+57 −41
Original line number Diff line number Diff line
@@ -166,56 +166,31 @@ 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 = executeWithPlayAuthRecovery(
            val appDetails = executeWithPlayAuthRecovery(
                operationName = "app details list",
                request = { getAppDetailsHelper().getAppByPackageName(packageNames) },
            )

            if (!isEmulator() && appDetails.all { it.versionCode == 0L }) {
                // Defensive fallback: validated auth should normally prevent this, but
                // Play can still return a limited response if auth degrades mid-request.
                Timber.i("Version code is 0.")

                appDetails = retryAfterPlayAuthRefresh(
                    operationName = "app details list",
                    reason = "all version codes are 0",
            val recoveredAppDetails = retryBatchAppDetailsWhenVersionCodesAreZero(
                appDetails = appDetails,
                request = { getAppDetailsHelper().getAppByPackageName(packageNames) },
            )

                if (appDetails.all { it.versionCode == 0L }) {
                    Timber.w("After refreshing auth, version code is still 0.")
                }
            }

            appDetails.filterNot { it.packageName.isBlank() || it.versionCode == 0L }
            recoveredAppDetails.filterNot { it.packageName.isBlank() || it.versionCode == 0L }
                .map { it.toApplication(context) }
        }

    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            var appDetails = executeWithPlayAuthRecovery(
            val appDetails = executeWithPlayAuthRecovery(
                operationName = "app details",
                request = { getAppDetailsOrThrowNotFound(packageName) },
            )

            if (!isEmulator() && appDetails.versionCode == 0L) {
                // Defensive fallback: validated auth should normally prevent this, but
                // Play can still return a limited response if auth degrades mid-request.
                Timber.i("Version code is 0 for ${appDetails.packageName}.")

                appDetails = retryAfterPlayAuthRefresh(
                    operationName = "app details",
                    reason = "version code is 0 for ${appDetails.packageName}",
            val recoveredAppDetails = retryAppDetailsWhenVersionCodeIsZero(
                appDetails = appDetails,
                request = { getAppDetailsOrThrowNotFound(packageName) },
            )

                if (appDetails.versionCode == 0L) {
                    Timber.w("After refreshing auth, version code is still 0. Giving up installation.")
                    throw IllegalStateException("App version code cannot be 0")
                }
            }

            appDetails.toApplication(context)
            recoveredAppDetails.toApplication(context)
        }

    private suspend fun getAppDetailsHelper(): AppDetailsHelper {
@@ -352,10 +327,6 @@ class PlayStoreRepository @Inject constructor(
        }
    }

    private fun shouldRetryPlayRequest(exception: GplayHttpRequestException): Boolean {
        return shouldRetryPlayRequest(exception.status)
    }

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

    private suspend fun retryBatchAppDetailsWhenVersionCodesAreZero(
        appDetails: List<GplayApp>,
        request: suspend () -> List<GplayApp>,
    ): List<GplayApp> {
        if (isEmulator() || appDetails.any { it.versionCode != 0L }) {
            return appDetails
        }

        Timber.i("Version code is 0 for all app details.")
        val refreshedAppDetails = retryAfterPlayAuthRefresh(
            operationName = "app details list",
            reason = "all version codes are 0",
            request = request,
        )

        if (refreshedAppDetails.all { it.versionCode == 0L }) {
            Timber.w("After refreshing auth, version code is still 0 for all app details.")
        }

        return refreshedAppDetails
    }

    private suspend fun retryAppDetailsWhenVersionCodeIsZero(
        appDetails: GplayApp,
        request: suspend () -> GplayApp,
    ): GplayApp {
        if (isEmulator() || appDetails.versionCode != 0L) {
            return appDetails
        }

        Timber.i("Version code is 0 for %s.", appDetails.packageName)
        val refreshedAppDetails = retryAfterPlayAuthRefresh(
            operationName = "app details",
            reason = "version code is 0 for ${appDetails.packageName}",
            request = request,
        )

        if (refreshedAppDetails.versionCode == 0L) {
            Timber.w("After refreshing auth, version code is still 0. Giving up installation.")
            throw IllegalStateException("App version code cannot be 0")
        }

        return refreshedAppDetails
    }

    suspend fun getAppDetailsWeb(packageName: String): Application? {
        val webAppDetailsHelper = WebAppDetailsHelper().using(gPlayHttpClient)

+8 −8
Original line number Diff line number Diff line
@@ -45,14 +45,14 @@ class LoginViewModel @Inject constructor(

    fun onEvent(event: LoginUiEvent) {
        when (event) {
            LoginUiEvent.AnonymousLoginSelected -> initialAnonymousLogin()
            LoginUiEvent.AnonymousLoginSelected -> initiateAnonymousLogin()
            is LoginUiEvent.GoogleLoginSubmitted ->
                initialGoogleLogin(event.email, event.oauthToken)
                initiateGoogleLogin(event.email, event.oauthToken)

            is LoginUiEvent.MicrogLoginSubmitted ->
                initialMicrogLogin(event.email, event.oauthToken)
                initiateMicrogLogin(event.email, event.oauthToken)

            LoginUiEvent.NoGoogleLoginSelected -> initialNoGoogleLogin()
            LoginUiEvent.NoGoogleLoginSelected -> initiateNoGoogleLogin()
            LoginUiEvent.LogoutRequested -> logout()
        }
    }
@@ -69,7 +69,7 @@ class LoginViewModel @Inject constructor(
        }
    }

    private fun initialMicrogLogin(
    private fun initiateMicrogLogin(
        email: String,
        oauthToken: String,
    ) {
@@ -80,7 +80,7 @@ class LoginViewModel @Inject constructor(
        }
    }

    private fun initialAnonymousLogin() {
    private fun initiateAnonymousLogin() {
        viewModelScope.launch {
            submitLogin(R.string.anonymous_login_failed_desc) {
                loginWorkflowCoordinator.submitAnonymousLogin()
@@ -88,7 +88,7 @@ class LoginViewModel @Inject constructor(
        }
    }

    private fun initialGoogleLogin(
    private fun initiateGoogleLogin(
        email: String,
        oauthToken: String,
    ) {
@@ -99,7 +99,7 @@ class LoginViewModel @Inject constructor(
        }
    }

    private fun initialNoGoogleLogin() {
    private fun initiateNoGoogleLogin() {
        viewModelScope.launch {
            submitLogin(R.string.something_went_wrong) {
                loginWorkflowCoordinator.submitNoGoogleLogin()
+11 −4
Original line number Diff line number Diff line
@@ -23,7 +23,9 @@ import com.aurora.gplayapi.helpers.AuthHelper
import dagger.Lazy
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.login.core.Auth
import foundation.e.apps.data.login.exceptions.GPlayLoginException
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -50,16 +52,21 @@ class AnonymousLoginManager @Inject constructor(
    override suspend fun login(): AuthData? {
        var authData: AuthData? = null
        withContext(Dispatchers.IO) {
            val requestPayload = nativeDeviceProperty.entries.associate { entry ->
                entry.key.toString() to entry.value.toString()
            }
            val response = gPlayHttpClient.get().postAuth(
                TOKEN_DISPENSER_URL,
                json.encodeToString(nativeDeviceProperty).toByteArray()
                json.encodeToString(requestPayload).toByteArray()
            )
            if (response.code != HttpURLConnection.HTTP_OK || !response.isSuccessful) {
                throw Exception(
                    "Error fetching Anonymous credentials\n" +
                throw GPlayLoginException(
                    isTimeout = false,
                    message = "Error fetching Anonymous credentials\n" +
                        "Network code: ${response.code}\n" +
                        "Success: ${response.isSuccessful}" +
                        response.errorString.run { if (isNotBlank()) "\nError message: $this" else "" }
                        response.errorString.run { if (isNotBlank()) "\nError message: $this" else "" },
                    loginMode = PlayStoreLoginMode.ANONYMOUS,
                )
            } else {
                val auth = json.decodeFromString<Auth>(String(response.responseBytes))
+53 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.login.playstore

import com.aurora.gplayapi.data.models.PlayResponse
import com.google.common.truth.Truth.assertThat
import dagger.Lazy
import foundation.e.apps.data.login.exceptions.GPlayLoginException
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.domain.auth.PlayStoreLoginMode
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
import java.net.HttpURLConnection
import java.util.Properties

class AnonymousLoginManagerTest {

    private fun <T> lazyOf(value: T): Lazy<T> = object : Lazy<T> {
        override fun get(): T = value
    }

    @Test
    fun `login throws typed gplay exception when token dispenser fails`() = runTest {
        val gPlayHttpClient = mockk<GPlayHttpClient>()
        every {
            gPlayHttpClient.postAuth(any(), any())
        } returns PlayResponse(
            isSuccessful = false,
            code = HttpURLConnection.HTTP_UNAVAILABLE,
            errorString = "service unavailable",
        )

        val manager = AnonymousLoginManager(
            gPlayHttpClient = lazyOf(gPlayHttpClient),
            nativeDeviceProperty = Properties(),
            json = Json {},
        )

        var failure: GPlayLoginException? = null
        try {
            manager.login()
        } catch (exception: GPlayLoginException) {
            failure = exception
        }

        assertThat(failure).isNotNull()
        assertThat(failure?.loginMode).isEqualTo(PlayStoreLoginMode.ANONYMOUS)
        assertThat(failure?.isTimeout).isFalse()
        assertThat(failure?.message).contains("Error fetching Anonymous credentials")
        assertThat(failure?.message).contains("Network code: 503")
    }
}