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

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

fix: implement microG token refresh when token expires

parent fbf6bae4
Loading
Loading
Loading
Loading
+42 −15
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@
 *
 */

package foundation.e.apps.data.login.playstore
package foundation.e.apps.data.login.microg

import android.accounts.Account
import android.accounts.AccountManager
@@ -29,7 +29,6 @@ import android.util.Base64
import androidx.core.os.BundleCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.data.login.api.LoginManager
import foundation.e.apps.data.login.microg.MicrogCertUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -41,10 +40,10 @@ data class MicrogAccount(
)

@Singleton
class MicrogAccountManager @Inject constructor(
class MicrogLoginManager @Inject constructor(
    @ApplicationContext val context: Context,
    private val accountManager: AccountManager
) : LoginManager<MicrogAccountManager.FetchResult> {
) : LoginManager<MicrogLoginManager.FetchResult> {
    sealed interface FetchResult {
        data class Success(val microgAccount: MicrogAccount) : FetchResult
        data class RequiresUserAction(val intent: Intent) : FetchResult
@@ -58,8 +57,9 @@ class MicrogAccountManager @Inject constructor(
    override suspend fun login(): FetchResult {
        return fetchMicrogAccount()
    }

    suspend fun fetchMicrogAccount(
        accountName: String? = null
        accountName: String = ""
    ): FetchResult = withContext(Dispatchers.IO) {
        val accounts = accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)
        if (accounts.isEmpty()) {
@@ -68,21 +68,14 @@ class MicrogAccountManager @Inject constructor(
            )
        }

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

        return@withContext runCatching {
            val options = createAuthTokenOptions()
            val 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)
                    )
                },
                options,
                false,
                null,
                null
@@ -104,6 +97,40 @@ class MicrogAccountManager @Inject constructor(
        }
    }

    suspend fun invalidateAuthToken(accountName: String, oldToken: String) {
        if (oldToken.isBlank()) {
            return
        }

        withContext(Dispatchers.IO) {
            val accounts =
                accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE)

            if (accounts.isNotEmpty()) {
                val account = resolveAccount(accounts, accountName)
                accountManager.invalidateAuthToken(account.type, oldToken)
            }
        }
    }

    private fun resolveAccount(accounts: Array<Account>, accountName: String): Account {
        return if (accountName.isNotBlank()) {
            accounts.firstOrNull { it.name == accountName } ?: accounts.first()
        } else {
            accounts.first()
        }
    }

    private fun createAuthTokenOptions(): Bundle {
        return Bundle().apply {
            putString("overridePackage", MicrogCertUtil.GOOGLE_PLAY_PACKAGE)
            putByteArray(
                "overrideCertificate",
                Base64.decode(MicrogCertUtil.GOOGLE_PLAY_CERT_BASE64, Base64.DEFAULT)
            )
        }
    }

    private fun <T : Parcelable> Bundle.parcelableCompat(key: String, clazz: Class<T>): T? {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            getParcelable(key, clazz)
+71 −0
Original line number Diff line number Diff line
package foundation.e.apps.data.login.microg

import foundation.e.apps.data.enums.User
import foundation.e.apps.data.preference.AppLoungeDataStore
import foundation.e.apps.data.preference.getSync
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MicrogTokenRefresher @Inject constructor(
    private val appLoungeDataStore: AppLoungeDataStore,
    private val microgLoginManager: MicrogLoginManager
) {
    suspend fun refreshIfNeeded(user: User): Boolean {
        val hasMicrogGoogleAccount = hasMicrogGoogleAccount(user)
        val oldToken = readOldToken(hasMicrogGoogleAccount)
        val shouldRefresh = shouldRefreshToken(hasMicrogGoogleAccount, oldToken)

        if (!shouldRefresh) {
            return true
        }

        val accountName = appLoungeDataStore.emailData.getSync().ifBlank { "" }
        return refreshToken(accountName, oldToken)
    }

    private suspend fun refreshToken(accountName: String, oldToken: String): Boolean {
        microgLoginManager.invalidateAuthToken(accountName, oldToken)
        val result = microgLoginManager.fetchMicrogAccount(accountName)
        return handleRefreshResult(result)
    }

    private suspend fun handleRefreshResult(result: MicrogLoginManager.FetchResult): Boolean {
        return when (result) {
            is MicrogLoginManager.FetchResult.Success -> {
                updateStoredTokens(result.microgAccount)
                true
            }
            is MicrogLoginManager.FetchResult.RequiresUserAction -> false
            is MicrogLoginManager.FetchResult.Error -> false
        }
    }

    private suspend fun updateStoredTokens(microgAccount: MicrogAccount) {
        appLoungeDataStore.saveGoogleLogin(
            microgAccount.account.name,
            microgAccount.oauthToken
        )
        appLoungeDataStore.saveAasToken("")
    }

    private fun hasMicrogGoogleAccount(user: User): Boolean {
        return user == User.GOOGLE && microgLoginManager.hasMicrogAccount()
    }

    private fun readOldToken(hasMicrogGoogleAccount: Boolean): String {
        return if (hasMicrogGoogleAccount) {
            appLoungeDataStore.oauthToken.getSync()
        } else {
            ""
        }
    }

    private fun shouldRefreshToken(hasMicrogGoogleAccount: Boolean, oldToken: String): Boolean {
        return hasMicrogGoogleAccount && oldToken.startsWith(MICROG_TOKEN_PREFIX)
    }

    private companion object {
        private const val MICROG_TOKEN_PREFIX = "ya29."
    }
}
+62 −19
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import foundation.e.apps.data.ResultSupreme
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.login.microg.MicrogTokenRefresher
import foundation.e.apps.data.preference.AppLoungeDataStore
import timber.log.Timber
import java.util.Locale
@@ -41,46 +42,88 @@ class PlayStoreAuthDataRefresher @Inject constructor(
    private val googleLoginManager: GoogleLoginManager,
    private val anonymousLoginManager: AnonymousLoginManager,
    private val authDataProviderFactory: PlayStoreAuthDataProviderFactory,
    private val microgAuthTokenRefresher: MicrogTokenRefresher,
    private val userProfileFetcher: UserProfileFetcher,
) {
    private val user: User
        get() = appLoungeDataStore.getUser()
    private val locale: Locale
        get() = context.resources.configuration.locales[0]

    private val loginManager: LoginManager<AuthData?>
        get() = if (user == User.GOOGLE) {
    private fun loginManagerFor(user: User): LoginManager<AuthData?> {
        return if (user == User.GOOGLE) {
            googleLoginManager
        } else {
            anonymousLoginManager
        }
    }

    private val locale: Locale
        get() = context.resources.configuration.locales[0]

    private val session: PlayStoreSession
        get() = PlayStoreSession(loginManager, user, locale)
    private fun sessionFor(user: User): PlayStoreSession {
        return PlayStoreSession(loginManagerFor(user), user, locale)
    }

    suspend fun validateAuthData(): ResultSupreme<AuthData?> {
        val user = appLoungeDataStore.getUser()
        val savedAuth = authDataCache.getSavedAuthData()
        if (isAuthDataValid(savedAuth)) {
            return ResultSupreme.create(ResultStatus.OK, savedAuth)
        }

        Timber.i("Validating AuthData...")
        return refreshAuthData()
        return refreshAuthData(user)
    }

    private suspend fun isAuthDataValid(savedAuth: AuthData?) =
        savedAuth != null && authDataVerifier.validate(savedAuth).isSuccessful

    private suspend fun refreshAuthData(): ResultSupreme<AuthData?> {
        val authData = fetchAuthDataForUserType()
        val data = authData.data ?: return ResultSupreme.create(ResultStatus.UNKNOWN)
        authDataCache.saveAuthData(data)
    private suspend fun refreshAuthData(
        user: User
    ): ResultSupreme<AuthData?> {
        val microgRefreshSuccessful = microgAuthTokenRefresher.refreshIfNeeded(user)
        val authDataResult =
            if (microgRefreshSuccessful) {
                fetchAuthDataForUserType(user)
            } else {
                ResultSupreme.Error("MicroG refresh failed")
            }

        val refreshedAuth = authDataResult.data ?: return authDataResult
        val profileAuth = applyUserProfile(user, refreshedAuth)

        if (profileAuth != authDataResult.data) {
            authDataResult.setData(profileAuth)
        }

        if (microgRefreshSuccessful) {
            authDataCache.saveAuthData(profileAuth)
        }

        return authDataResult
    }

    private suspend fun applyUserProfile(user: User, authData: AuthData): AuthData {
        if (user != User.GOOGLE) {
            return authData
        }

    private suspend fun fetchAuthDataForUserType(): ResultSupreme<AuthData?> {
        val provider = authDataProviderFactory.create(appLoungeDataStore.getUser(), session)
            ?: return ResultSupreme.Error("User type not ANONYMOUS or GOOGLE")
        return provider.fetch()
        val fetchedProfile = userProfileFetcher.fetch(authData)
        return if (fetchedProfile == null) {
            authData
        } else {
            authData.copy(userProfile = fetchedProfile)
        }
    }

    private suspend fun fetchAuthDataForUserType(user: User): ResultSupreme<AuthData?> {
        val provider = authDataProviderFactory.create(user, sessionFor(user))
        return if (provider == null) {
            ResultSupreme.Error("User type not ANONYMOUS or GOOGLE")
        } else {
            val result = provider.fetch()
            val authData = result.data
            if (authData == null) {
                ResultSupreme.Error(result.message, result.exception)
            } else {
                ResultSupreme.replicate(result, authData)
            }
        }
    }
}
+39 −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.login.playstore

import com.aurora.gplayapi.data.models.AuthData
import com.aurora.gplayapi.data.models.UserProfile
import com.aurora.gplayapi.helpers.UserProfileHelper
import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class UserProfileFetcher @Inject constructor(
    private val gPlayHttpClient: GPlayHttpClient
) {
    suspend fun fetch(authData: AuthData): UserProfile? = withContext(Dispatchers.IO) {
        runCatching {
            UserProfileHelper(authData).using(gPlayHttpClient).getUserProfile()
        }.getOrNull()
    }
}
+38 −5
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 = 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.")
@@ -181,8 +185,10 @@ class PlayStoreRepository @Inject constructor(

    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            var appDetails: GplayApp = try {
            var appDetails = 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)

Loading