diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/GoogleSignInManager.kt b/app/src/main/kotlin/org/db3/airmq/features/login/GoogleSignInManager.kt index d16b1ed..37d6671 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/GoogleSignInManager.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/GoogleSignInManager.kt @@ -15,6 +15,7 @@ import androidx.credentials.exceptions.NoCredentialException import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import kotlin.coroutines.cancellation.CancellationException import org.db3.airmq.R sealed interface GoogleSignInResult { @@ -51,12 +52,17 @@ suspend fun launchGoogleSignIn(context: Context): GoogleSignInResult { } catch (error: GetCredentialCancellationException) { logGoogleSignInError(error) GoogleSignInResult.Cancelled + } catch (error: GetCredentialInterruptedException) { + logGoogleSignInError(error) + GoogleSignInResult.Cancelled } catch (error: GetCredentialException) { logGoogleSignInError(error) GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed)) } catch (error: GoogleIdTokenParsingException) { Log.e(GOOGLE_SIGN_IN_TAG, "Google ID token parsing failed", error) GoogleSignInResult.Error(context.getString(R.string.toast_oauth_failed)) + } catch (e: CancellationException) { + throw e } catch (error: Throwable) { GoogleSignInResult.Error(error.message ?: context.getString(R.string.toast_oauth_failed)) } diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt index 0873160..529b131 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt @@ -213,6 +213,7 @@ private fun LoginScreenContent( containerColor = Color.White, contentColor = Color(0xFF202124), modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading, onClick = { onEvent(Event.GoogleClicked) } ) diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt index e995f13..0d1571d 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt @@ -35,6 +35,7 @@ class LoginViewModel @Inject constructor( fun onEvent(event: Event) { when (event) { Event.GoogleClicked -> { + if (_uiState.value.isLoading) return _uiState.value = _uiState.value.copy(isLoading = true) _actions.tryEmit(Action.LaunchGoogleSignIn) } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt index eece682..408c579 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt @@ -84,9 +84,25 @@ class FirebaseAuthService @Inject constructor( private suspend fun signInWithGoogle(token: String): User { localEmailAuthStore.clearProfile().getOrThrow() val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token) - val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken) - apiTokenStore.saveToken(apiToken).getOrThrow() - return firebaseSessionUser.user + try { + val apiToken = exchangeFirebaseIdTokenWithBackend(firebaseSessionUser.firebaseAccessToken) + apiTokenStore.saveToken(apiToken).getOrThrow() + return firebaseSessionUser.user + } catch (e: Throwable) { + rollbackSessionAfterFailedGoogleBackendAuth() + throw e + } + } + + /** Firebase may already be signed in when the backend exchange fails; clear everything so the user stays logged out. */ + private suspend fun rollbackSessionAfterFailedGoogleBackendAuth() { + try { + firebaseSessionManager.signOut() + apiTokenStore.clearToken().getOrThrow() + localEmailAuthStore.clearProfile().getOrThrow() + } catch (_: Throwable) { + // Best-effort; caller still receives the original sign-in failure. + } } private suspend fun commitEmailAuthSession( @@ -114,9 +130,9 @@ class FirebaseAuthService @Inject constructor( ) } - private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String { + private suspend fun exchangeFirebaseIdTokenWithBackend(firebaseIdToken: String): String { val response = apolloClient - .mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken)) + .mutation(AuthGoogleNewMutation(accessToken = firebaseIdToken)) .execute() response.exception?.let { throw it } response.errors?.firstOrNull()?.let { gqlError -> diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt index 2f22068..3cd714c 100644 --- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -51,6 +51,7 @@ class FirebaseAuthServiceTest { assertTrue(result.isFailure) assertEquals("backend failed", result.exceptionOrNull()?.message) assertEquals(null, tokenStore.storedToken) + assertEquals(1, sessionManager.signOutCallCount) } @Test @@ -131,6 +132,9 @@ private class FakeFirebaseSessionManager( private val signInError: Throwable? = null, private val isSignedIn: Boolean = true ) : FirebaseSessionManager { + var signOutCallCount = 0 + private set + override suspend fun getUser(): User? = signInResult?.user override suspend fun isSignedIn(): Boolean = isSignedIn @@ -140,7 +144,9 @@ private class FakeFirebaseSessionManager( return signInResult ?: error("No signInResult configured") } - override suspend fun signOut() = Unit + override suspend fun signOut() { + signOutCallCount++ + } } private class FakeApiTokenStore(