Loading app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +57 −41 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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() Loading @@ -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) Loading app/src/main/java/foundation/e/apps/feature/auth/login/LoginViewModel.kt +8 −8 Original line number Diff line number Diff line Loading @@ -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() } } Loading @@ -69,7 +69,7 @@ class LoginViewModel @Inject constructor( } } private fun initialMicrogLogin( private fun initiateMicrogLogin( email: String, oauthToken: String, ) { Loading @@ -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() Loading @@ -88,7 +88,7 @@ class LoginViewModel @Inject constructor( } } private fun initialGoogleLogin( private fun initiateGoogleLogin( email: String, oauthToken: String, ) { Loading @@ -99,7 +99,7 @@ class LoginViewModel @Inject constructor( } } private fun initialNoGoogleLogin() { private fun initiateNoGoogleLogin() { viewModelScope.launch { submitLogin(R.string.something_went_wrong) { loginWorkflowCoordinator.submitNoGoogleLogin() Loading data/src/main/java/foundation/e/apps/data/login/playstore/AnonymousLoginManager.kt +11 −4 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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)) Loading data/src/test/java/foundation/e/apps/data/login/playstore/AnonymousLoginManagerTest.kt 0 → 100644 +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") } } Loading
app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +57 −41 Original line number Diff line number Diff line Loading @@ -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 { Loading Loading @@ -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() Loading @@ -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) Loading
app/src/main/java/foundation/e/apps/feature/auth/login/LoginViewModel.kt +8 −8 Original line number Diff line number Diff line Loading @@ -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() } } Loading @@ -69,7 +69,7 @@ class LoginViewModel @Inject constructor( } } private fun initialMicrogLogin( private fun initiateMicrogLogin( email: String, oauthToken: String, ) { Loading @@ -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() Loading @@ -88,7 +88,7 @@ class LoginViewModel @Inject constructor( } } private fun initialGoogleLogin( private fun initiateGoogleLogin( email: String, oauthToken: String, ) { Loading @@ -99,7 +99,7 @@ class LoginViewModel @Inject constructor( } } private fun initialNoGoogleLogin() { private fun initiateNoGoogleLogin() { viewModelScope.launch { submitLogin(R.string.something_went_wrong) { loginWorkflowCoordinator.submitNoGoogleLogin() Loading
data/src/main/java/foundation/e/apps/data/login/playstore/AnonymousLoginManager.kt +11 −4 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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)) Loading
data/src/test/java/foundation/e/apps/data/login/playstore/AnonymousLoginManagerTest.kt 0 → 100644 +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") } }