Loading app/src/main/java/foundation/e/apps/data/login/playstore/MicrogAccountManager.kt +53 −0 Original line number Diff line number Diff line Loading @@ -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) { Loading app/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreAuthDataRefresher.kt +45 −4 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() Loading Loading @@ -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?> { Loading @@ -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 } } } app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +37 −4 Original line number Diff line number Diff line Loading @@ -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, Loading @@ -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.") Loading @@ -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() Loading @@ -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.") Loading @@ -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 app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthDataRefresherTest.kt +31 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, Loading @@ -41,6 +43,7 @@ class PlayStoreAuthDataRefresherTest { googleLoginManager = googleLoginManager, anonymousLoginManager = anonymousLoginManager, authDataProviderFactory = authDataProviderFactory, microgAccountManager = microgAccountManager, ) @Test Loading @@ -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 Loading @@ -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 Loading @@ -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) } } Loading
app/src/main/java/foundation/e/apps/data/login/playstore/MicrogAccountManager.kt +53 −0 Original line number Diff line number Diff line Loading @@ -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) { Loading
app/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreAuthDataRefresher.kt +45 −4 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() Loading Loading @@ -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?> { Loading @@ -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 } } }
app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +37 −4 Original line number Diff line number Diff line Loading @@ -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, Loading @@ -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.") Loading @@ -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() Loading @@ -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.") Loading @@ -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
app/src/test/java/foundation/e/apps/data/login/playstore/PlayStoreAuthDataRefresherTest.kt +31 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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, Loading @@ -41,6 +43,7 @@ class PlayStoreAuthDataRefresherTest { googleLoginManager = googleLoginManager, anonymousLoginManager = anonymousLoginManager, authDataProviderFactory = authDataProviderFactory, microgAccountManager = microgAccountManager, ) @Test Loading @@ -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 Loading @@ -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 Loading @@ -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) } }