Loading app/src/main/AndroidManifest.xml +15 −11 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.GET_TASKS" /> <uses-permission android:name="android.permission.GET_ACCOUNTS" /> <uses-permission android:name="foundation.e.pwaplayer.provider.READ_WRITE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> Loading @@ -45,6 +46,9 @@ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> <uses-permission android:name="android.permission.USE_CREDENTIALS" /> <uses-permission android:name="android.permission.INSTALL_PACKAGES" tools:ignore="ProtectedPermissions" /> Loading app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt +4 −0 Original line number Diff line number Diff line Loading @@ -72,6 +72,10 @@ class AuthenticatorRepository @Inject constructor( loginCommon.saveGoogleLogin(email, oauth) } suspend fun saveAasToken(aasToken: String) { appLoungeDataStore.saveAasToken(aasToken) } suspend fun setNoGoogleMode() { loginCommon.setNoGoogleMode() } Loading app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt +12 −2 Original line number Diff line number Diff line Loading @@ -183,8 +183,9 @@ class PlayStoreAuthenticator @Inject constructor( /* * If aasToken is not yet saved / made, fetch it from email and oauthToken. */ val googleLoginManager = loginManager as GoogleLoginManager val aasTokenResponse = loginWrapper.getAasToken( loginManager as GoogleLoginManager, googleLoginManager, email, oauthToken ) Loading @@ -195,7 +196,16 @@ class PlayStoreAuthenticator @Inject constructor( * in the aasTokenResponse. */ if (!aasTokenResponse.isSuccess()) { return ResultSupreme.replicate(aasTokenResponse, null) /* * Fallback: try building auth data directly from the oauthtoken without * converting to AAS. */ val fallbackAuth = googleLoginManager.buildAuthDataFromOauthToken(oauthToken) return if (fallbackAuth != null) { ResultSupreme.Success(formatAuthData(fallbackAuth)) } else { ResultSupreme.replicate(aasTokenResponse, null) } } val aasTokenFetched = aasTokenResponse.data ?: "" Loading app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt +30 −2 Original line number Diff line number Diff line Loading @@ -62,13 +62,41 @@ class GoogleLoginManager( override suspend fun login(): AuthData? { val email = appLoungeDataStore.emailData.getSync() val aasToken = appLoungeDataStore.aasToken.getSync() val oauthToken = appLoungeDataStore.oauthToken.getSync() var authData: AuthData? withContext(Dispatchers.IO) { // Prefer AAS if present, otherwise fall back to an AUTH token (microG ya29.*). val (token, tokenType) = when { aasToken.isNotBlank() && aasToken.startsWith("ya29.") -> aasToken to AuthHelper.Token.AUTH aasToken.isNotBlank() -> aasToken to AuthHelper.Token.AAS oauthToken.isNotBlank() -> oauthToken to AuthHelper.Token.AUTH else -> "" to AuthHelper.Token.AAS } authData = AuthHelper.build( email, aasToken, tokenType = AuthHelper.Token.AAS, token, tokenType = tokenType, isAnonymous = false, properties = nativeDeviceProperty ) } return authData } suspend fun buildAuthDataFromOauthToken(oauthToken: String): AuthData? { val email = appLoungeDataStore.emailData.getSync() var authData: AuthData? withContext(Dispatchers.IO) { authData = AuthHelper.build( email = email, token = oauthToken, tokenType = AuthHelper.Token.AUTH, isAnonymous = false, properties = nativeDeviceProperty ) } Loading app/src/main/java/foundation/e/apps/data/login/microg/MicrogAccountManager.kt 0 → 100644 +110 −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.microg import android.accounts.Account import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Parcelable import android.util.Base64 import androidx.core.os.BundleCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton data class MicrogAccount( val account: Account, val oauthToken: String ) sealed class MicrogAccountFetchResult { data class Success(val microgAccount: MicrogAccount) : MicrogAccountFetchResult() data class RequiresUserAction(val intent: Intent) : MicrogAccountFetchResult() data class Error(val throwable: Throwable) : MicrogAccountFetchResult() } @Singleton class MicrogAccountManager @Inject constructor( @ApplicationContext val context: Context, private val accountManager: AccountManager ) { fun hasMicrogAccount(): Boolean { return accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE).isNotEmpty() } suspend fun fetchMicrogAccount( accountName: String? = null ): MicrogAccountFetchResult = withContext(Dispatchers.IO) { val accounts = accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) if (accounts.isEmpty()) { return@withContext MicrogAccountFetchResult.Error( IllegalStateException("No Google accounts available") ) } val account = accountName?.let { name -> accounts.firstOrNull { it.name == name } } ?: accounts.first() return@withContext runCatching { 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) ) }, false, null, null ).result val intent = bundle.parcelableCompat(AccountManager.KEY_INTENT, Intent::class.java) if (intent != null) { return@withContext MicrogAccountFetchResult.RequiresUserAction(intent) } val token = bundle.getString(AccountManager.KEY_AUTHTOKEN) ?: return@withContext MicrogAccountFetchResult.Error( IllegalStateException("microG returned an empty token") ) MicrogAccountFetchResult.Success(MicrogAccount(account, token)) }.getOrElse { throwable -> MicrogAccountFetchResult.Error(throwable) } } 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) } else { BundleCompat.getParcelable(this, key, clazz) } } } Loading
app/src/main/AndroidManifest.xml +15 −11 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.GET_TASKS" /> <uses-permission android:name="android.permission.GET_ACCOUNTS" /> <uses-permission android:name="foundation.e.pwaplayer.provider.READ_WRITE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> Loading @@ -45,6 +46,9 @@ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> <uses-permission android:name="android.permission.USE_CREDENTIALS" /> <uses-permission android:name="android.permission.INSTALL_PACKAGES" tools:ignore="ProtectedPermissions" /> Loading
app/src/main/java/foundation/e/apps/data/login/AuthenticatorRepository.kt +4 −0 Original line number Diff line number Diff line Loading @@ -72,6 +72,10 @@ class AuthenticatorRepository @Inject constructor( loginCommon.saveGoogleLogin(email, oauth) } suspend fun saveAasToken(aasToken: String) { appLoungeDataStore.saveAasToken(aasToken) } suspend fun setNoGoogleMode() { loginCommon.setNoGoogleMode() } Loading
app/src/main/java/foundation/e/apps/data/login/PlayStoreAuthenticator.kt +12 −2 Original line number Diff line number Diff line Loading @@ -183,8 +183,9 @@ class PlayStoreAuthenticator @Inject constructor( /* * If aasToken is not yet saved / made, fetch it from email and oauthToken. */ val googleLoginManager = loginManager as GoogleLoginManager val aasTokenResponse = loginWrapper.getAasToken( loginManager as GoogleLoginManager, googleLoginManager, email, oauthToken ) Loading @@ -195,7 +196,16 @@ class PlayStoreAuthenticator @Inject constructor( * in the aasTokenResponse. */ if (!aasTokenResponse.isSuccess()) { return ResultSupreme.replicate(aasTokenResponse, null) /* * Fallback: try building auth data directly from the oauthtoken without * converting to AAS. */ val fallbackAuth = googleLoginManager.buildAuthDataFromOauthToken(oauthToken) return if (fallbackAuth != null) { ResultSupreme.Success(formatAuthData(fallbackAuth)) } else { ResultSupreme.replicate(aasTokenResponse, null) } } val aasTokenFetched = aasTokenResponse.data ?: "" Loading
app/src/main/java/foundation/e/apps/data/login/api/GoogleLoginManager.kt +30 −2 Original line number Diff line number Diff line Loading @@ -62,13 +62,41 @@ class GoogleLoginManager( override suspend fun login(): AuthData? { val email = appLoungeDataStore.emailData.getSync() val aasToken = appLoungeDataStore.aasToken.getSync() val oauthToken = appLoungeDataStore.oauthToken.getSync() var authData: AuthData? withContext(Dispatchers.IO) { // Prefer AAS if present, otherwise fall back to an AUTH token (microG ya29.*). val (token, tokenType) = when { aasToken.isNotBlank() && aasToken.startsWith("ya29.") -> aasToken to AuthHelper.Token.AUTH aasToken.isNotBlank() -> aasToken to AuthHelper.Token.AAS oauthToken.isNotBlank() -> oauthToken to AuthHelper.Token.AUTH else -> "" to AuthHelper.Token.AAS } authData = AuthHelper.build( email, aasToken, tokenType = AuthHelper.Token.AAS, token, tokenType = tokenType, isAnonymous = false, properties = nativeDeviceProperty ) } return authData } suspend fun buildAuthDataFromOauthToken(oauthToken: String): AuthData? { val email = appLoungeDataStore.emailData.getSync() var authData: AuthData? withContext(Dispatchers.IO) { authData = AuthHelper.build( email = email, token = oauthToken, tokenType = AuthHelper.Token.AUTH, isAnonymous = false, properties = nativeDeviceProperty ) } Loading
app/src/main/java/foundation/e/apps/data/login/microg/MicrogAccountManager.kt 0 → 100644 +110 −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.microg import android.accounts.Account import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Parcelable import android.util.Base64 import androidx.core.os.BundleCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton data class MicrogAccount( val account: Account, val oauthToken: String ) sealed class MicrogAccountFetchResult { data class Success(val microgAccount: MicrogAccount) : MicrogAccountFetchResult() data class RequiresUserAction(val intent: Intent) : MicrogAccountFetchResult() data class Error(val throwable: Throwable) : MicrogAccountFetchResult() } @Singleton class MicrogAccountManager @Inject constructor( @ApplicationContext val context: Context, private val accountManager: AccountManager ) { fun hasMicrogAccount(): Boolean { return accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE).isNotEmpty() } suspend fun fetchMicrogAccount( accountName: String? = null ): MicrogAccountFetchResult = withContext(Dispatchers.IO) { val accounts = accountManager.getAccountsByType(MicrogCertUtil.GOOGLE_ACCOUNT_TYPE) if (accounts.isEmpty()) { return@withContext MicrogAccountFetchResult.Error( IllegalStateException("No Google accounts available") ) } val account = accountName?.let { name -> accounts.firstOrNull { it.name == name } } ?: accounts.first() return@withContext runCatching { 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) ) }, false, null, null ).result val intent = bundle.parcelableCompat(AccountManager.KEY_INTENT, Intent::class.java) if (intent != null) { return@withContext MicrogAccountFetchResult.RequiresUserAction(intent) } val token = bundle.getString(AccountManager.KEY_AUTHTOKEN) ?: return@withContext MicrogAccountFetchResult.Error( IllegalStateException("microG returned an empty token") ) MicrogAccountFetchResult.Success(MicrogAccount(account, token)) }.getOrElse { throwable -> MicrogAccountFetchResult.Error(throwable) } } 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) } else { BundleCompat.getParcelable(this, key, clazz) } } }