diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 65ea606dd..1027b9cab 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -1,7 +1,6 @@ package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -14,27 +13,32 @@ import kotlinx.coroutines.tasks.await /** * Creates a remembered launcher function for anonymous sign-in. * + * @param config Authentication UI configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A launcher function that starts the anonymous sign-in flow when invoked * * @see signInAnonymously * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts */ @Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(config: AuthUIConfiguration): () -> Unit { +internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( + config: AuthUIConfiguration, + onSignInFailure: (AuthException) -> Unit = {}, +): () -> Unit { val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() - return remember(this) { - { - coroutineScope.launch { - try { - signInAnonymously(config) - } catch (e: AuthException) { - // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInAnonymously(config) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index d6a9622e7..6cefb834a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -19,8 +19,10 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import com.facebook.AccessToken import com.facebook.CallbackManager import com.facebook.FacebookCallback @@ -47,6 +49,7 @@ import kotlinx.coroutines.launch * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return A launcher function that starts the Facebook sign-in flow when invoked * @@ -58,10 +61,15 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( config: AuthUIConfiguration, provider: AuthProvider.Facebook, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() + val currentContext by rememberUpdatedState(context) + val currentConfig by rememberUpdatedState(config) + val currentProvider by rememberUpdatedState(provider) + val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) val launcher = rememberLauncherForActivityResult( loginManager.createLogInActivityResultContract( @@ -71,7 +79,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( onResult = {}, ) - DisposableEffect(config) { + DisposableEffect(Unit) { loginManager.registerCallback( callbackManager, object : FacebookCallback { @@ -79,17 +87,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( coroutineScope.launch { try { signInWithFacebook( - context = context, - config = config, - provider = provider, + context = currentContext, + config = currentConfig, + provider = currentProvider, accessToken = result.accessToken, ) } catch (e: AuthException) { // Already an AuthException, don't re-wrap it updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, currentContext) updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } } } @@ -100,12 +110,13 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error, context) + val authException = AuthException.from(error, currentContext) updateAuthState( AuthState.Error( authException ) ) + currentOnSignInFailure(authException) } }) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 89837df3e..c8b51fcce 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialException @@ -47,6 +46,7 @@ import kotlinx.coroutines.launch * @param context Android context for Credential Manager * @param config Authentication UI configuration * @param provider Google provider configuration with server client ID and optional scopes + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A callback function that initiates Google Sign-In when invoked * * @see signInWithGoogle @@ -57,19 +57,20 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Google, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() - return remember(this, config) { - { - coroutineScope.launch { - try { - signInWithGoogle(context, config, provider) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithGoogle(context, config, provider) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index add6bb235..58d35cea0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -41,6 +40,7 @@ import kotlinx.coroutines.tasks.await * * @param config Authentication UI configuration * @param provider OAuth provider configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return Lambda that triggers OAuth sign-in when invoked * @@ -54,6 +54,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( activity: Activity?, config: AuthUIConfiguration, provider: AuthProvider.OAuth, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() activity ?: throw IllegalStateException( @@ -61,22 +62,22 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( "Ensure FirebaseAuthScreen is used within an Activity." ) - return remember(this, provider.providerId, config) { - { - coroutineScope.launch { - try { - signInWithProvider( - context = context, - config = config, - activity = activity, - provider = provider - ) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithProvider( + context = context, + config = config, + activity = activity, + provider = provider + ) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 848f0693a..993767290 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -188,6 +188,7 @@ fun FirebaseAuthScreen( ) ) }, + onSignInFailure = onSignInFailure, ) val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } @@ -1012,6 +1013,7 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( config: AuthUIConfiguration, onNavigate: (AuthRoute) -> Unit, onUnknownProvider: ((AuthProvider) -> Unit)? = null, + onSignInFailure: (AuthException) -> Unit = {}, ): (AuthProvider) -> Unit { val anonymousProvider = config.providers.filterIsInstance().firstOrNull() val googleProvider = config.providers.filterIsInstance().firstOrNull() @@ -1023,16 +1025,18 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( val twitterProvider = config.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = config.providers.filterIsInstance() - val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config) } - val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) } - val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) } - val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) } + val onSignInWithFacebook = facebookProvider?.let { + rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure) + } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } val genericOAuthHandlers = genericOAuthProviders.associateWith { - rememberOAuthSignInHandler(context, activity, config, it) + rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } return { provider -> diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt index 6e71c4170..18f396f9b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -38,6 +39,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers @@ -54,6 +56,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class AnonymousAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -214,6 +219,35 @@ class AnonymousAuthProviderFirebaseAuthUITest { assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) } + // ============================================================================================= + // rememberAnonymousSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberAnonymousSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val networkException = FirebaseNetworkException("Network error") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberAnonymousSignInHandler( + config = config, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.NetworkException::class.java) + } + // ============================================================================================= // Anonymous Account Upgrade Tests // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 81f94b161..0d5ea4e51 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.core.net.toUri import androidx.credentials.CredentialManager import androidx.test.core.app.ApplicationProvider @@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock @@ -67,6 +69,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class GoogleAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -920,4 +925,59 @@ class GoogleAuthProviderFirebaseAuthUITest { val finalState = instance.authStateFlow().first { it !is AuthState.Loading } assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } + + // ============================================================================================= + // rememberGoogleSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberGoogleSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + // A picker-level failure that used to never reach onSignInFailure at all: + // the outer fallback throws AuthException.UnknownException when no Google accounts are found. + instance.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): AuthProvider.Google.GoogleSignInResult { + throw AuthException.UnknownException( + "No Google accounts available.\n\nPlease add a Google account to your device and try again." + ) + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) = Unit + } + + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberGoogleSignInHandler( + context = applicationContext, + config = config, + provider = googleProvider, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.UnknownException::class.java) + } }