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

Commit fa5b8b5c authored by Jonathan Klee's avatar Jonathan Klee
Browse files

fix: implement microG token refresh when token expires

parent a6a40478
Loading
Loading
Loading
Loading
+53 −0
Original line number Diff line number Diff line
@@ -58,6 +58,59 @@ class MicrogAccountManager @Inject constructor(
    override suspend fun login(): FetchResult {
        return fetchMicrogAccount()
    }

    suspend fun refreshMicrogAccount(
        accountName: String?,
        oldToken: String
    ): FetchResult = withContext(Dispatchers.IO) {
        val accounts: Array<Account> =
            accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)
        if (accounts.isEmpty()) {
            return@withContext FetchResult.Error(
                IllegalStateException("No Google accounts available")
            )
        }

        val account: Account = accountName?.let { name ->
            accounts.firstOrNull { it.name == name }
        } ?: accounts.first()

        if (oldToken.isNotBlank()) {
            accountManager.invalidateAuthToken(account.type, oldToken)
        }

        return@withContext runCatching {
            val bundle: Bundle = accountManager.getAuthToken(
                account,
                MicrogCertUtil.PLAY_AUTH_SCOPE,
                Bundle().apply {
                    putString("overridePackage", MicrogCertUtil.GOOGLE_PLAY_PACKAGE)
                    putByteArray(
                        "overrideCertificate",
                        Base64.decode(MicrogCertUtil.GOOGLE_PLAY_CERT_BASE64, Base64.DEFAULT)
                    )
                },
                true,
                null,
                null
            ).result

            val intent: Intent? = bundle.parcelableCompat(AccountManager.KEY_INTENT, Intent::class.java)
            if (intent != null) {
                return@withContext FetchResult.RequiresUserAction(intent)
            }

            val token: String = bundle.getString(AccountManager.KEY_AUTHTOKEN)
                ?: return@withContext FetchResult.Error(
                    IllegalStateException("microG returned an empty token")
                )

            FetchResult.Success(MicrogAccount(account, token))
        }.getOrElse { throwable ->
            FetchResult.Error(throwable)
        }
    }

    suspend fun fetchMicrogAccount(
        accountName: String? = null
    ): FetchResult = withContext(Dispatchers.IO) {
+45 −4
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.User
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.data.preference.getSync
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@@ -41,6 +42,7 @@ class PlayStoreAuthDataRefresher @Inject constructor(
    private val googleLoginManager: GoogleLoginManager,
    private val anonymousLoginManager: AnonymousLoginManager,
    private val authDataProviderFactory: PlayStoreAuthDataProviderFactory,
    private val microgLoginManager: MicrogLoginManager,
) {
    private val user: User
        get() = appLoungeDataStore.getUser()
@@ -72,10 +74,21 @@ class PlayStoreAuthDataRefresher @Inject constructor(
        savedAuth != null && authDataVerifier.validate(savedAuth).isSuccessful

    private suspend fun refreshAuthData(): ResultSupreme<AuthData?> {
        val authData = fetchAuthDataForUserType()
        val data = authData.data ?: return ResultSupreme.create(ResultStatus.UNKNOWN)
        val isMicrogRefreshOk: Boolean = refreshMicrogOauthTokenIfNeeded()
        val authDataResult: ResultSupreme<AuthData?> = if (isMicrogRefreshOk) {
            fetchAuthDataForUserType()
        } else {
            ResultSupreme.Error("MicroG refresh failed")
        }
        val data: AuthData? = authDataResult.data
        return if (!isMicrogRefreshOk) {
            authDataResult
        } else if (data != null) {
            authDataCache.saveAuthData(data)
        return authData
            authDataResult
        } else {
            ResultSupreme.create(ResultStatus.UNKNOWN)
        }
    }

    private suspend fun fetchAuthDataForUserType(): ResultSupreme<AuthData?> {
@@ -83,4 +96,32 @@ class PlayStoreAuthDataRefresher @Inject constructor(
            ?: return ResultSupreme.Error("User type not ANONYMOUS or GOOGLE")
        return provider.fetch()
    }

    private suspend fun refreshMicrogOauthTokenIfNeeded(): Boolean {
        if (user != User.GOOGLE || !microgLoginManager.hasMicrogAccount()) {
            return true
        }

        val oldToken: String = appLoungeDataStore.oauthToken.getSync()
        if (!oldToken.startsWith("ya29.")) {
            return true
        }

        val accountName: String? = appLoungeDataStore.emailData.getSync().ifBlank { null }
        val result: MicrogLoginManager.FetchResult =
            microgLoginManager.refreshMicrogAccount(accountName, oldToken)
        return when (result) {
            is MicrogLoginManager.FetchResult.Success -> {
                appLoungeDataStore.saveGoogleLogin(
                    result.microgAccount.account.name,
                    result.microgAccount.oauthToken
                )
                appLoungeDataStore.saveAasToken("")
                true
            }

            is MicrogLoginManager.FetchResult.RequiresUserAction -> false
            is MicrogLoginManager.FetchResult.Error -> false
        }
    }
}
+37 −4
Original line number Diff line number Diff line
@@ -159,7 +159,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> = retryOnUnauthorized {
                getAppDetailsHelper().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,
@@ -168,7 +170,9 @@ class PlayStoreRepository @Inject constructor(

                refreshPlayStoreAuthentication()

                appDetails = getAppDetailsHelper().getAppByPackageName(packageNames)
                appDetails = retryOnUnauthorized {
                    getAppDetailsHelper().getAppByPackageName(packageNames)
                }

                if (appDetails.all { it.versionCode == 0L }) {
                    Timber.w("After refreshing auth, version code is still 0.")
@@ -182,7 +186,9 @@ class PlayStoreRepository @Inject constructor(
    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            var appDetails: GplayApp = try {
                retryOnUnauthorized {
                    getAppDetailsHelper().getAppByPackageName(packageName)
                }
            } catch (exception: GplayHttpRequestException) {
                if (exception.status == HttpURLConnection.HTTP_NOT_FOUND) {
                    throw InternalException.AppNotFound()
@@ -197,7 +203,9 @@ class PlayStoreRepository @Inject constructor(

                refreshPlayStoreAuthentication()

                appDetails = getAppDetailsHelper().getAppByPackageName(packageName)
                appDetails = retryOnUnauthorized {
                    getAppDetailsHelper().getAppByPackageName(packageName)
                }

                if (appDetails.versionCode == 0L) {
                    Timber.w("After refreshing auth, version code is still 0. Giving up installation.")
@@ -220,6 +228,31 @@ class PlayStoreRepository @Inject constructor(
        authenticatorRepository.fetchAuthObjects(listOf(StoreType.PLAY_STORE))
    }

    private suspend fun <T> retryOnUnauthorized(block: suspend () -> T): T {
        return try {
            block()
        } catch (exception: Exception) {
            val isUnauthorized: Boolean = when (exception) {
                is GplayHttpRequestException ->
                    exception.status == HttpURLConnection.HTTP_UNAUTHORIZED
                is InternalException.AppNotFound ->
                    gPlayHttpClient.responseCode.value == HttpURLConnection.HTTP_UNAUTHORIZED
                else -> false
            }
            if (!isUnauthorized) {
                throw exception
            }

            val refreshResult = authenticatorRepository.getValidatedAuthData()
            val isSuccess: Boolean = refreshResult.isSuccess() && refreshResult.data != null
            if (!isSuccess) {
                throw exception
            }

            block()
        }
    }

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

+31 −0
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -32,6 +33,7 @@ class PlayStoreAuthDataRefresherTest {
    private val googleLoginManager: GoogleLoginManager = mockk()
    private val anonymousLoginManager: AnonymousLoginManager = mockk()
    private val authDataProviderFactory: PlayStoreAuthDataProviderFactory = mockk()
    private val microgAccountManager: MicrogAccountManager = mockk()

    private val refresher = PlayStoreAuthDataRefresher(
        context = context,
@@ -41,6 +43,7 @@ class PlayStoreAuthDataRefresherTest {
        googleLoginManager = googleLoginManager,
        anonymousLoginManager = anonymousLoginManager,
        authDataProviderFactory = authDataProviderFactory,
        microgAccountManager = microgAccountManager,
    )

    @Test
@@ -51,6 +54,7 @@ class PlayStoreAuthDataRefresherTest {
        }

        every { appLoungeDataStore.getUser() } returns User.GOOGLE
        every { microgAccountManager.hasMicrogAccount() } returns false
        every { authDataCache.getSavedAuthData() } returns savedAuth
        coEvery { authDataVerifier.validate(savedAuth) } returns response

@@ -76,6 +80,7 @@ class PlayStoreAuthDataRefresherTest {
        val sessionSlot = slot<PlayStoreSession>()

        every { appLoungeDataStore.getUser() } returns User.GOOGLE
        every { microgAccountManager.hasMicrogAccount() } returns false
        every { authDataCache.getSavedAuthData() } returns savedAuth
        coEvery { authDataVerifier.validate(savedAuth) } returns response
        every { authDataProviderFactory.create(User.GOOGLE, capture(sessionSlot)) } returns provider
@@ -85,4 +90,30 @@ class PlayStoreAuthDataRefresherTest {
        assertThat(result.data).isEqualTo(refreshedAuth)
        coVerify { authDataCache.saveAuthData(refreshedAuth) }
    }

    @Test
    fun validateAuthData_skipsMicrogRefreshWhenTokenIsNotMicrog() = runTest {
        val refreshedAuth = AuthData(email = "refreshed")
        val response = mockk<PlayResponse> {
            every { isSuccessful } returns false
        }
        val provider = object : AuthDataProvider {
            override suspend fun fetch(): ResultSupreme<AuthData?> {
                return ResultSupreme.Success(refreshedAuth)
            }
        }
        val sessionSlot = slot<PlayStoreSession>()

        every { appLoungeDataStore.getUser() } returns User.GOOGLE
        every { microgLoginManager.hasMicrogAccount() } returns true
        every { appLoungeDataStore.oauthToken } returns flowOf("oauth2_4/abc")
        every { authDataCache.getSavedAuthData() } returns null
        coEvery { authDataVerifier.validate(null) } returns response
        every { authDataProviderFactory.create(User.GOOGLE, capture(sessionSlot)) } returns provider

        val result = refresher.validateAuthData()

        assertThat(result).isInstanceOf(ResultSupreme.Success::class.java)
        assertThat(result.data).isEqualTo(refreshedAuth)
    }
}