Loading app/src/main/java/foundation/e/apps/feature/auth/login/LoginWorkflowCoordinator.kt +13 −5 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ import foundation.e.apps.domain.auth.AuthSession import foundation.e.apps.domain.auth.AuthSessionRepository import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.auth.describe import foundation.e.apps.domain.login.AnonymousLoginUseCase import foundation.e.apps.domain.login.GoogleLoginUseCase import foundation.e.apps.domain.login.MicrogLoginUseCase Loading Loading @@ -272,16 +273,23 @@ class LoginWorkflowCoordinator @Inject constructor( } Timber.e(rollbackFailure, "Login rollback failed after %s", rollbackContext) rollbackFailure.apply { if (failure is LoginWorkflowFailure.SubmissionFailed) { addSuppressed(failure.throwable) } } rollbackFailure.addSuppressed(failure.toThrowable()) return LoginWorkflowResult.Failure( LoginWorkflowFailure.SubmissionFailed(rollbackFailure) ) } private fun LoginWorkflowFailure.toThrowable(): Throwable { return when (this) { is LoginWorkflowFailure.SubmissionFailed -> throwable is LoginWorkflowFailure.RefreshValidationFailed -> IllegalStateException( "Original login refresh validation failed for $preferredStore: " + (authError?.describe() ?: "unknown auth error") ) } } private suspend fun captureRollbackState(): LoginRollbackState { return LoginRollbackState( session = authSessionRepository.resolveCurrentSession(), Loading app/src/test/java/foundation/e/apps/feature/auth/login/LoginWorkflowCoordinatorTest.kt +5 −0 Original line number Diff line number Diff line Loading @@ -358,6 +358,11 @@ class LoginWorkflowCoordinatorTest { .isEqualTo("Failed to rollback login after refresh validation failure for PLAY_STORE") assertThat(throwable.cause).isInstanceOf(IllegalStateException::class.java) assertThat(throwable.cause?.message).isEqualTo("rollback failed") assertThat(throwable.suppressed).hasLength(1) assertThat(throwable.suppressed.first().message) .isEqualTo( "Original login refresh validation failed for PLAY_STORE: Google login failed" ) } private fun playStoreSnapshot(loginMode: PlayStoreLoginMode): AuthRefreshSnapshot { Loading data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +33 −7 Original line number Diff line number Diff line Loading @@ -28,20 +28,46 @@ import foundation.e.apps.domain.auth.describe import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class PlayStoreTokenRefreshHandler @Inject constructor( private val authDataCache: AuthDataCache, private val playStoreAuthenticator: PlayStoreAuthenticator, private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, ) : TokenRefreshHandler { class PlayStoreTokenRefreshHandler : TokenRefreshHandler { companion object { private const val RECENT_REFRESH_WINDOW_IN_MILLIS = 5_000L } private val authDataCache: AuthDataCache private val playStoreAuthenticator: PlayStoreAuthenticator private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy private val monotonicClockMs: () -> Long @Inject constructor( authDataCache: AuthDataCache, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, ) : this( authDataCache = authDataCache, playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, monotonicClockMs = { TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) }, ) constructor( authDataCache: AuthDataCache, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, monotonicClockMs: () -> Long, ) { this.authDataCache = authDataCache this.playStoreAuthenticator = playStoreAuthenticator this.playStoreStoredAuthPolicy = playStoreStoredAuthPolicy this.monotonicClockMs = monotonicClockMs } private val refreshMutex = Mutex() private var lastSuccessfulAuthToken: String? = null private var lastSuccessfulRefreshTimestamp: Long = 0L Loading Loading @@ -79,13 +105,13 @@ class PlayStoreTokenRefreshHandler @Inject constructor( private fun wasRecentlyRefreshed(currentAuthData: AuthData?): Boolean { val authToken = currentAuthData?.authToken ?: return false return authToken == lastSuccessfulAuthToken && System.currentTimeMillis() - lastSuccessfulRefreshTimestamp <= monotonicClockMs() - lastSuccessfulRefreshTimestamp <= RECENT_REFRESH_WINDOW_IN_MILLIS } private fun markRefreshSuccessful(authData: AuthData) { lastSuccessfulAuthToken = authData.authToken lastSuccessfulRefreshTimestamp = System.currentTimeMillis() lastSuccessfulRefreshTimestamp = monotonicClockMs() } private fun shouldEvictPersistedAuth(error: AuthError): Boolean { Loading data/src/main/java/foundation/e/apps/data/preference/PlayStoreCredentialsDataStore.kt +15 −5 Original line number Diff line number Diff line Loading @@ -138,11 +138,12 @@ class PlayStoreCredentialsDataStore @Inject constructor( playStoreAuthSourceFlow.first() override suspend fun saveAuthData(authData: AuthData?) { val encodedAuthData = authData?.let(::encodeAuthData) dataStore.edit { if (authData == null) { if (encodedAuthData == null) { it.remove(AUTHDATA) } else { it[AUTHDATA] = json.encodeToString(authData) it[AUTHDATA] = encodedAuthData } } } Loading Loading @@ -188,11 +189,12 @@ class PlayStoreCredentialsDataStore @Inject constructor( } override suspend fun restoreCredentials(snapshot: PlayStoreAuthStore.CredentialsSnapshot) { val encodedAuthData = snapshot.authData?.let(::encodeAuthData) dataStore.edit { preferences -> if (snapshot.authData == null) { if (encodedAuthData == null) { preferences.remove(AUTHDATA) } else { preferences[AUTHDATA] = json.encodeToString(snapshot.authData) preferences[AUTHDATA] = encodedAuthData } if (snapshot.email.isBlank()) { Loading Loading @@ -223,12 +225,20 @@ class PlayStoreCredentialsDataStore @Inject constructor( private fun decodeAuthData(rawAuthData: String): AuthData? { return try { json.decodeFromString<AuthData>(rawAuthData) json.decodeFromString(AuthData.serializer(), rawAuthData) } catch (exception: SerializationException) { null } } private fun encodeAuthData(authData: AuthData): String { return try { json.encodeToString(AuthData.serializer(), authData) } catch (exception: SerializationException) { throw IllegalStateException("Failed to encode Play Store auth data", exception) } } private fun buildAccount(authData: AuthData?, email: String): PlayStoreAccount? { val resolvedEmail = email.ifBlank { authData?.email.orEmpty() } return if (authData == null && resolvedEmail.isBlank()) { Loading data/src/main/java/foundation/e/apps/data/preference/SessionPreferencesDataStore.kt +1 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,7 @@ class SessionPreferencesDataStore @Inject constructor( "NO_GOOGLE" -> PersistedLoginIntent.OPEN_SOURCE "ANONYMOUS" -> PersistedLoginIntent.PLAY_ANONYMOUS "GOOGLE" -> PersistedLoginIntent.PLAY_GOOGLE "MICROG" -> PersistedLoginIntent.PLAY_MICROG else -> PersistedLoginIntent.NONE } } Loading Loading
app/src/main/java/foundation/e/apps/feature/auth/login/LoginWorkflowCoordinator.kt +13 −5 Original line number Diff line number Diff line Loading @@ -22,6 +22,7 @@ import foundation.e.apps.domain.auth.AuthSession import foundation.e.apps.domain.auth.AuthSessionRepository import foundation.e.apps.domain.auth.AuthStore import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.auth.describe import foundation.e.apps.domain.login.AnonymousLoginUseCase import foundation.e.apps.domain.login.GoogleLoginUseCase import foundation.e.apps.domain.login.MicrogLoginUseCase Loading Loading @@ -272,16 +273,23 @@ class LoginWorkflowCoordinator @Inject constructor( } Timber.e(rollbackFailure, "Login rollback failed after %s", rollbackContext) rollbackFailure.apply { if (failure is LoginWorkflowFailure.SubmissionFailed) { addSuppressed(failure.throwable) } } rollbackFailure.addSuppressed(failure.toThrowable()) return LoginWorkflowResult.Failure( LoginWorkflowFailure.SubmissionFailed(rollbackFailure) ) } private fun LoginWorkflowFailure.toThrowable(): Throwable { return when (this) { is LoginWorkflowFailure.SubmissionFailed -> throwable is LoginWorkflowFailure.RefreshValidationFailed -> IllegalStateException( "Original login refresh validation failed for $preferredStore: " + (authError?.describe() ?: "unknown auth error") ) } } private suspend fun captureRollbackState(): LoginRollbackState { return LoginRollbackState( session = authSessionRepository.resolveCurrentSession(), Loading
app/src/test/java/foundation/e/apps/feature/auth/login/LoginWorkflowCoordinatorTest.kt +5 −0 Original line number Diff line number Diff line Loading @@ -358,6 +358,11 @@ class LoginWorkflowCoordinatorTest { .isEqualTo("Failed to rollback login after refresh validation failure for PLAY_STORE") assertThat(throwable.cause).isInstanceOf(IllegalStateException::class.java) assertThat(throwable.cause?.message).isEqualTo("rollback failed") assertThat(throwable.suppressed).hasLength(1) assertThat(throwable.suppressed.first().message) .isEqualTo( "Original login refresh validation failed for PLAY_STORE: Google login failed" ) } private fun playStoreSnapshot(loginMode: PlayStoreLoginMode): AuthRefreshSnapshot { Loading
data/src/main/java/foundation/e/apps/data/login/playstore/PlayStoreTokenRefreshHandler.kt +33 −7 Original line number Diff line number Diff line Loading @@ -28,20 +28,46 @@ import foundation.e.apps.domain.auth.describe import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class PlayStoreTokenRefreshHandler @Inject constructor( private val authDataCache: AuthDataCache, private val playStoreAuthenticator: PlayStoreAuthenticator, private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, ) : TokenRefreshHandler { class PlayStoreTokenRefreshHandler : TokenRefreshHandler { companion object { private const val RECENT_REFRESH_WINDOW_IN_MILLIS = 5_000L } private val authDataCache: AuthDataCache private val playStoreAuthenticator: PlayStoreAuthenticator private val playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy private val monotonicClockMs: () -> Long @Inject constructor( authDataCache: AuthDataCache, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, ) : this( authDataCache = authDataCache, playStoreAuthenticator = playStoreAuthenticator, playStoreStoredAuthPolicy = playStoreStoredAuthPolicy, monotonicClockMs = { TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) }, ) constructor( authDataCache: AuthDataCache, playStoreAuthenticator: PlayStoreAuthenticator, playStoreStoredAuthPolicy: PlayStoreStoredAuthPolicy, monotonicClockMs: () -> Long, ) { this.authDataCache = authDataCache this.playStoreAuthenticator = playStoreAuthenticator this.playStoreStoredAuthPolicy = playStoreStoredAuthPolicy this.monotonicClockMs = monotonicClockMs } private val refreshMutex = Mutex() private var lastSuccessfulAuthToken: String? = null private var lastSuccessfulRefreshTimestamp: Long = 0L Loading Loading @@ -79,13 +105,13 @@ class PlayStoreTokenRefreshHandler @Inject constructor( private fun wasRecentlyRefreshed(currentAuthData: AuthData?): Boolean { val authToken = currentAuthData?.authToken ?: return false return authToken == lastSuccessfulAuthToken && System.currentTimeMillis() - lastSuccessfulRefreshTimestamp <= monotonicClockMs() - lastSuccessfulRefreshTimestamp <= RECENT_REFRESH_WINDOW_IN_MILLIS } private fun markRefreshSuccessful(authData: AuthData) { lastSuccessfulAuthToken = authData.authToken lastSuccessfulRefreshTimestamp = System.currentTimeMillis() lastSuccessfulRefreshTimestamp = monotonicClockMs() } private fun shouldEvictPersistedAuth(error: AuthError): Boolean { Loading
data/src/main/java/foundation/e/apps/data/preference/PlayStoreCredentialsDataStore.kt +15 −5 Original line number Diff line number Diff line Loading @@ -138,11 +138,12 @@ class PlayStoreCredentialsDataStore @Inject constructor( playStoreAuthSourceFlow.first() override suspend fun saveAuthData(authData: AuthData?) { val encodedAuthData = authData?.let(::encodeAuthData) dataStore.edit { if (authData == null) { if (encodedAuthData == null) { it.remove(AUTHDATA) } else { it[AUTHDATA] = json.encodeToString(authData) it[AUTHDATA] = encodedAuthData } } } Loading Loading @@ -188,11 +189,12 @@ class PlayStoreCredentialsDataStore @Inject constructor( } override suspend fun restoreCredentials(snapshot: PlayStoreAuthStore.CredentialsSnapshot) { val encodedAuthData = snapshot.authData?.let(::encodeAuthData) dataStore.edit { preferences -> if (snapshot.authData == null) { if (encodedAuthData == null) { preferences.remove(AUTHDATA) } else { preferences[AUTHDATA] = json.encodeToString(snapshot.authData) preferences[AUTHDATA] = encodedAuthData } if (snapshot.email.isBlank()) { Loading Loading @@ -223,12 +225,20 @@ class PlayStoreCredentialsDataStore @Inject constructor( private fun decodeAuthData(rawAuthData: String): AuthData? { return try { json.decodeFromString<AuthData>(rawAuthData) json.decodeFromString(AuthData.serializer(), rawAuthData) } catch (exception: SerializationException) { null } } private fun encodeAuthData(authData: AuthData): String { return try { json.encodeToString(AuthData.serializer(), authData) } catch (exception: SerializationException) { throw IllegalStateException("Failed to encode Play Store auth data", exception) } } private fun buildAccount(authData: AuthData?, email: String): PlayStoreAccount? { val resolvedEmail = email.ifBlank { authData?.email.orEmpty() } return if (authData == null && resolvedEmail.isBlank()) { Loading
data/src/main/java/foundation/e/apps/data/preference/SessionPreferencesDataStore.kt +1 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,7 @@ class SessionPreferencesDataStore @Inject constructor( "NO_GOOGLE" -> PersistedLoginIntent.OPEN_SOURCE "ANONYMOUS" -> PersistedLoginIntent.PLAY_ANONYMOUS "GOOGLE" -> PersistedLoginIntent.PLAY_GOOGLE "MICROG" -> PersistedLoginIntent.PLAY_MICROG else -> PersistedLoginIntent.NONE } } Loading