diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt index 6d09582973..2f61822d60 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -379,13 +379,33 @@ abstract class AuthException( * } * ``` * + * Messages are resolved against [context]'s own configuration, so this overload honours + * neither a custom [AuthUIStringProvider] nor the `locale` a host configured. Prefer the + * [AuthUIStringProvider] overload wherever one is reachable, which inside an auth flow it + * always is, as `config.stringProvider`. This overload exists for the entry points that + * genuinely have no configuration to draw on, such as [FirebaseAuthUI.signOut], + * [FirebaseAuthUI.withReauth] and [FirebaseAuthUI.delete]. + * * @param firebaseException The Firebase exception to convert + * @param context Used to build a [DefaultAuthUIStringProvider] for the error messages * @return An appropriate [AuthException] subtype */ @JvmStatic fun from(firebaseException: Exception, context: Context): AuthException = from(firebaseException, DefaultAuthUIStringProvider(context)) + /** + * Creates an [AuthException] from [firebaseException], taking message text from + * [stringProvider] so it honours the host's configured strings and locale. + * + * This is the preferred overload; see the [Context] one above for the exception mapping + * table and an example. A `null` [stringProvider], or one whose resource for a given error + * is blank, falls back to the Firebase SDK's own message. + * + * @param firebaseException The Firebase exception to convert + * @param stringProvider Supplies localized message text; pass `config.stringProvider` + * @return An appropriate [AuthException] subtype + */ @JvmStatic @JvmOverloads fun from(firebaseException: Exception, stringProvider: AuthUIStringProvider? = null): AuthException { 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 3c5f2df498..4f9fe0d891 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 @@ -22,7 +22,6 @@ import kotlinx.coroutines.tasks.await internal fun AuthFlowScope.rememberAnonymousSignInHandler( onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { - val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() return { coroutineScope.launch { @@ -33,7 +32,7 @@ internal fun AuthFlowScope.rememberAnonymousSignInHandler( emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } @@ -63,7 +62,7 @@ internal suspend fun AuthFlowScope.signInAnonymously() { emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index bb1d277926..ea7e7e707a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -192,7 +192,7 @@ internal suspend fun AuthFlowScope.createOrLinkUserWithEmailAndPassword( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -351,7 +351,7 @@ internal suspend fun AuthFlowScope.signInWithEmailAndPassword( throw e } catch (e: Exception) { val authException = recoverLegacyDifferentSignInMethod(email, e) - ?: AuthException.from(e, context) + ?: AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -365,7 +365,7 @@ private suspend fun AuthFlowScope.recoverLegacyDifferentSignInMethod( return null } - val authException = AuthException.from(cause) + val authException = AuthException.from(cause, config.stringProvider) if (authException !is AuthException.InvalidCredentialsException && authException !is AuthException.UserNotFoundException) { return null @@ -500,7 +500,7 @@ internal suspend fun AuthFlowScope.signInAndLinkWithCredential( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -565,7 +565,7 @@ internal suspend fun AuthFlowScope.sendSignInLinkToEmail( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -702,7 +702,7 @@ internal suspend fun AuthFlowScope.signInWithEmailLink( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -824,7 +824,7 @@ internal suspend fun AuthFlowScope.sendPasswordResetEmail( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw 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 0a8a7212be..cf47eb217e 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 @@ -95,7 +95,7 @@ internal fun AuthFlowScope.rememberSignInWithFacebookLauncher( currentScope.emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, currentContext) + val authException = AuthException.from(e, currentScope.config.stringProvider) currentScope.emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } @@ -108,7 +108,7 @@ internal fun AuthFlowScope.rememberSignInWithFacebookLauncher( override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error, currentContext) + val authException = AuthException.from(error, currentScope.config.stringProvider) currentScope.emit( AuthState.Error( authException @@ -203,7 +203,7 @@ internal suspend fun AuthFlowScope.signInWithFacebook( emit(AuthState.Error(e)) throw e } catch (e: FacebookException) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } catch (e: CancellationException) { @@ -217,7 +217,7 @@ internal suspend fun AuthFlowScope.signInWithFacebook( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw 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 96ffbb79f3..0c3dcaf81b 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 @@ -38,7 +38,7 @@ internal fun AuthFlowScope.rememberGoogleSignInHandler( emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } @@ -73,7 +73,7 @@ internal suspend fun AuthFlowScope.signInWithGoogle( authorizationProvider.authorize(context, requestedScopes) } catch (e: Exception) { // Continue with sign-in even if scope authorization fails - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) } } @@ -193,7 +193,7 @@ internal suspend fun AuthFlowScope.signInWithGoogle( throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw 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 1b5fd3db8d..86e40f3d11 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 @@ -49,7 +49,7 @@ internal fun AuthFlowScope.rememberOAuthSignInHandler( emit(AuthState.Error(e)) if (e !is AuthException.AuthCancelledException) onSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } @@ -191,7 +191,7 @@ internal suspend fun AuthFlowScope.signInWithProvider( throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index d7c6757a2a..bfbf387747 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -68,7 +68,7 @@ internal suspend fun AuthFlowScope.verifyPhoneNumber( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -107,7 +107,7 @@ internal suspend fun AuthFlowScope.submitVerificationCode( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } @@ -161,7 +161,7 @@ internal suspend fun AuthFlowScope.signInWithPhoneAuthCredential( emit(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, config.stringProvider) emit(AuthState.Error(authException)) throw authException } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt index a5b73917e5..89a4a82ed2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt @@ -19,10 +19,12 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider /** * CompositionLocal for accessing the top-level dialog controller from any composable. @@ -40,7 +42,7 @@ val LocalTopLevelDialogController = compositionLocalOf AuthState ) { + /** + * Only ever set by the deprecated constructor below. A caller that passed a provider without + * also providing [LocalAuthUIStringProvider] still works, instead of trading a compile-time + * argument for a runtime `error("No AuthUIStringProvider provided")`. + */ + private var explicitStringProvider: AuthUIStringProvider? = null + + @Deprecated( + "The string provider is now read from LocalAuthUIStringProvider at render time.", + ReplaceWith("TopLevelDialogController(currentAuthState)") + ) + constructor( + stringProvider: AuthUIStringProvider, + currentAuthState: () -> AuthState + ) : this(currentAuthState) { + explicitStringProvider = stringProvider + } + private var dialogState by mutableStateOf(null) private val shownErrorStates = mutableSetOf() @@ -125,11 +147,14 @@ class TopLevelDialogController( /** * Composable that renders the current dialog, if any. * This should be called once at the root level of your auth flow. - * - * Uses the existing [ErrorRecoveryDialog] component. + * + * Uses the existing [ErrorRecoveryDialog] component. Strings come from + * [LocalAuthUIStringProvider], read here at render time, unless the controller was built + * through the deprecated constructor that takes one explicitly. */ @Composable fun CurrentDialog() { + val stringProvider = explicitStringProvider ?: LocalAuthUIStringProvider.current val state = dialogState when (state) { is DialogState.ErrorDialog -> { @@ -174,16 +199,40 @@ class TopLevelDialogController( * live auth state on every [TopLevelDialogController.showErrorDialog] call without being * recreated (and losing its de-duplication history) whenever the auth state changes. * - * Keyed on [stringProvider] rather than left unkeyed: callers must pass a `remember`ed - * [stringProvider] (stable across recompositions), otherwise the controller — and its - * de-duplication history — would be recreated on every recomposition. + * The `remember` is deliberately unkeyed, so any key would be a way to lose a dialog that was + * just shown. Nothing kept across recompositions goes stale as a result: strings are resolved + * from [LocalAuthUIStringProvider] at render time, and [authState] is read through + * [rememberUpdatedState] rather than captured, so the first composition's lambda is not pinned + * for the controller's life. + */ +@Composable +fun rememberTopLevelDialogController( + authState: () -> AuthState +): TopLevelDialogController { + val currentAuthState by rememberUpdatedState(authState) + return remember { + TopLevelDialogController { currentAuthState() } + } +} + +/** + * Creates and remembers a [TopLevelDialogController] bound to an explicit [stringProvider]. + * + * Kept only for source compatibility. It still keys the `remember` on [stringProvider], so a + * caller whose provider is not stable across recompositions loses the controller's state — that + * is the reason to move to the single-argument overload above. */ +@Deprecated( + "The string provider is now read from LocalAuthUIStringProvider at render time.", + ReplaceWith("rememberTopLevelDialogController(authState)") +) @Composable fun rememberTopLevelDialogController( stringProvider: AuthUIStringProvider, authState: () -> AuthState ): TopLevelDialogController { return remember(stringProvider) { + @Suppress("DEPRECATION") TopLevelDialogController(stringProvider, authState) } } 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 c986fcd4c5..a3faed3ac0 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 @@ -78,7 +78,6 @@ import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandl import com.firebase.ui.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController @@ -181,7 +180,9 @@ fun FirebaseAuthScreen( val activity = LocalActivity.current val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val stringProvider = remember(context) { DefaultAuthUIStringProvider(context) } + // The host's provider, not one built from LocalContext: only this honours a custom + // AuthUIStringProvider and the configured locale. + val stringProvider = configuration.stringProvider // The reauth effects below run outside composition, so they cannot call stringResource // themselves. @@ -202,7 +203,7 @@ fun FirebaseAuthScreen( hostAuthFlowScope(authUI, configuration, hostStateHolder) } val authState = rawAuthState - val dialogController = rememberTopLevelDialogController(stringProvider) { authState } + val dialogController = rememberTopLevelDialogController { authState } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } 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 937f698d0f..72ece3340d 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 @@ -23,6 +23,8 @@ import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp @@ -422,4 +424,53 @@ class AnonymousAuthProviderFirebaseAuthUITest { ArgumentMatchers.anyString() ) } + + // ============================================================================================= + // Error message routing — the configured AuthUIStringProvider, not the device + // ============================================================================================= + + @Test + fun `signInAnonymously - failure message comes from the configured string provider`() = + runTest { + // "A network error has occurred", in Japanese. + val localizedMessage = "ネットワークエラーが発生しました" + val networkException = FirebaseNetworkException("A network error has occurred.") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(taskCompletionSource.task) + + val localizedConfig = authUIConfiguration { + context = applicationContext + providers { + provider(AuthProvider.Anonymous) + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorNetworkGeneric: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(localizedConfig).signInAnonymously() + } catch (t: Throwable) { + thrown = t + } + + // Without the configured provider the conversion keeps Firebase's own English text. + assertThat(thrown).isInstanceOf(AuthException.NetworkException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt index 4cb63bc4f9..7ee51ee126 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt @@ -24,6 +24,8 @@ import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.PasswordRule import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.firebase.ui.auth.util.MockPersistenceManager import com.google.android.gms.tasks.TaskCompletionSource @@ -35,9 +37,11 @@ import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthException import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.SignInMethodQueryResult @@ -2030,4 +2034,331 @@ class EmailAuthProviderFirebaseAuthUITest { val state = instance.authStateFlow().first { it !is AuthState.Loading } assertThat(state).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } + + // ============================================================================================= + // Error message routing — the configured AuthUIStringProvider, not the device + // + // Every test here overrides exactly one member of the provider and asserts that string comes + // back on both the thrown exception and the emitted AuthState.Error. Reverting the call site + // to `AuthException.from(e)` or `AuthException.from(e, context)` leaves Firebase's own English + // text in place and fails the test. + // ============================================================================================= + + /** A provider whose only difference from the default is [errorWeakPasswordGeneric]. */ + private fun weakPasswordProvider(message: String): AuthUIStringProvider = + object : AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorWeakPasswordGeneric: String = message + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - weak password message comes from the configured string provider`() = + runTest { + // "The password is too weak", in Japanese. + val localizedMessage = "パスワードが弱すぎます" + val weakPasswordException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "The given password is invalid.", + "Password should be at least 6 characters" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(weakPasswordException) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`( + mockFirebaseAuth.createUserWithEmailAndPassword( + "test@example.com", + "Pass@123" + ) + ).thenReturn(taskCompletionSource.task) + + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + stringProvider = weakPasswordProvider(localizedMessage) + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( + context = applicationContext, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.WeakPasswordException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `createOrLinkUserWithEmailAndPassword - policy violation with no listed requirements uses the configured string provider`() = + runTest { + // "The password does not meet the requirements", in Japanese. + val localizedMessage = "パスワードが要件を満たしていません" + // No bracketed requirement list, so the message has to come from the provider. + val policyException = FirebaseAuthWeakPasswordException( + "ERROR_WEAK_PASSWORD", + "The given password is invalid.", + "PASSWORD_DOES_NOT_MEET_REQUIREMENTS" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(policyException) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`( + mockFirebaseAuth.createUserWithEmailAndPassword( + "test@example.com", + "Pass@123" + ) + ).thenReturn(taskCompletionSource.task) + + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(emailProvider) } + stringProvider = weakPasswordProvider(localizedMessage) + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).createOrLinkUserWithEmailAndPassword( + context = applicationContext, + provider = emailProvider, + name = null, + email = "test@example.com", + password = "Pass@123" + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown) + .isInstanceOf(AuthException.PasswordPolicyViolationException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `signInWithEmailAndPassword - user-not-found message comes from the configured string provider`() = + runTest { + // "No account was found for that email address", in Japanese. + val localizedMessage = "そのメールアドレスのアカウントは見つかりませんでした" + val userNotFoundException = FirebaseAuthInvalidUserException( + "ERROR_USER_NOT_FOUND", + "User not found" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(userNotFoundException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(taskCompletionSource.task) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorUserNotFound: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).signInWithEmailAndPassword( + context = applicationContext, + email = "test@example.com", + password = "Pass@123" + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.UserNotFoundException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `signInAndLinkWithCredential - failure message comes from the configured string provider`() = + runTest { + // "Those credentials are not valid", in Japanese. + val localizedMessage = "その認証情報は有効ではありません" + val credential = GoogleAuthProvider.getCredential("google-id-token", null) + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_CREDENTIAL", + "Invalid credential" + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`(mockFirebaseAuth.signInWithCredential(credential)) + .thenReturn(taskCompletionSource.task) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorInvalidCredentials: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).signInAndLinkWithCredential(credential) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `sendSignInLinkToEmail - failure message comes from the configured string provider`() = + runTest { + // "Too many attempts. Please try again later", in Japanese. + val localizedMessage = "試行回数が多すぎます。しばらくしてからもう一度お試しください" + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + val tooManyRequests = + object : FirebaseAuthException("ERROR_TOO_MANY_REQUESTS", "Too many requests") {} + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(tooManyRequests) + `when`(mockFirebaseAuth.sendSignInLinkToEmail(anyString(), any())) + .thenReturn(taskCompletionSource.task) + + val provider = AuthProvider.Email( + isEmailLinkSignInEnabled = true, + emailLinkActionCodeSettings = ActionCodeSettings.newBuilder() + .setUrl("https://example.com") + .setHandleCodeInApp(true) + .setAndroidPackageName("com.test", true, null) + .build(), + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorTooManyRequests: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).sendSignInLinkToEmail( + context = applicationContext, + provider = provider, + email = "test@example.com", + credentialForLinking = null + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.TooManyRequestsException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `sendPasswordResetEmail - failure message comes from the configured string provider`() = + runTest { + // "This account has been disabled", in Japanese. + val localizedMessage = "このアカウントは無効になっています" + val disabledException = FirebaseAuthInvalidUserException( + "ERROR_USER_DISABLED", + "The user account has been disabled by an administrator." + ) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(disabledException) + `when`( + mockFirebaseAuth.sendPasswordResetEmail( + ArgumentMatchers.eq("test@example.com"), + ArgumentMatchers.isNull() + ) + ).thenReturn(taskCompletionSource.task) + + val config = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorUserDisabled: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).sendPasswordResetEmail("test@example.com") + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt index db2931f03d..1b41464b9a 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProviderFirebaseAuthUI.kt @@ -27,10 +27,13 @@ import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Facebook.FacebookProfileData +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.util.EmailLinkPersistenceManager import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseNetworkException import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult @@ -356,4 +359,100 @@ class FacebookAuthProviderFirebaseAuthUITest { assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) } } + + // ============================================================================================= + // Error message routing — the configured AuthUIStringProvider, not the device + // ============================================================================================= + + @Test + @Config(manifest = Config.NONE, qualifiers = "night") + fun `signInWithFacebook - FacebookException message comes from the configured string provider`() = + runTest { + // "An unknown error occurred during sign-in", in Japanese. + val localizedMessage = "サインイン中に不明なエラーが発生しました" + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = spy(AuthProvider.Facebook()) + val config = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorUnknownAuth: String = localizedMessage + } + } + + val mockAccessToken = mock { + on { token } doReturn "error-token" + } + doAnswer { + throw FacebookException("Graph error") + }.whenever(provider).fetchFacebookProfile(any()) + + var thrown: Throwable? = null + try { + instance.flowScope(config).signInWithFacebook( + context = applicationContext, + provider = provider, + accessToken = mockAccessToken, + credentialProvider = mockFBAuthCredentialProvider + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first { it is AuthState.Error } + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + @Config(manifest = Config.NONE, qualifiers = "night") + fun `signInWithFacebook - credential failure message comes from the configured string provider`() = + runTest { + // "A network error has occurred", in Japanese. + val localizedMessage = "ネットワークエラーが発生しました" + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val provider = spy(AuthProvider.Facebook()) + val config = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorNetworkGeneric: String = localizedMessage + } + } + + val mockAccessToken = mock { + on { token } doReturn "network-token" + } + doReturn(null).whenever(provider).fetchFacebookProfile(any()) + // A FirebaseException that is not a FirebaseAuthException, so it maps to + // NetworkException. Raised from the token exchange, which sits in signInWithFacebook's + // own body rather than in the delegated signInAndLinkWithCredential. + doAnswer { + throw FirebaseNetworkException("A network error has occurred.") + }.whenever(mockFBAuthCredentialProvider).getCredential("network-token") + + var thrown: Throwable? = null + try { + instance.flowScope(config).signInWithFacebook( + context = applicationContext, + provider = provider, + accessToken = mockAccessToken, + credentialProvider = mockFBAuthCredentialProvider + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.NetworkException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first { it is AuthState.Error } + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } 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 8aaffe0040..a8a3238786 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 @@ -27,6 +27,9 @@ import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.recordingScope import com.google.android.gms.common.api.Scope import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat @@ -1115,4 +1118,112 @@ class GoogleAuthProviderFirebaseAuthUITest { assertThat(reportedFailures).isEmpty() } + + // ============================================================================================= + // Error message routing — the configured AuthUIStringProvider, not the device + // ============================================================================================= + + @Test + fun `signInWithGoogle - credential manager failure message comes from the configured string provider`() = + runTest { + // "An unknown error occurred during sign-in", in Japanese. + val localizedMessage = "サインイン中に不明なエラーが発生しました" + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenThrow(RuntimeException("No credentials available")) + + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(googleProvider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorUnknownAuth: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).signInWithGoogle( + context = applicationContext, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + + @Test + fun `signInWithGoogle - scope authorization failure message comes from the configured string provider`() = + runTest { + // "An unknown error occurred during sign-in", in Japanese. + val localizedMessage = "サインイン中に不明なエラーが発生しました" + `when`(mockAuthorizationProvider.authorize(eq(applicationContext), any())) + .thenThrow(RuntimeException("Authorization failed")) + // Sign-in continues past the authorization failure, so the Error state is transient: + // a recording scope keeps it instead of letting the later states overwrite it. + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenAnswer { throw AuthException.AuthCancelledException("stop here") } + + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = listOf("https://www.googleapis.com/auth/drive") + ) + val config = authUIConfiguration { + context = applicationContext + providers { provider(googleProvider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorUnknownAuth: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val recorded = mutableListOf() + try { + instance.recordingScope(config, recorded).signInWithGoogle( + context = applicationContext, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + } catch (_: Throwable) { + // The cancellation that stops the flow after the authorization failure. + } + + verify(mockAuthorizationProvider).authorize(eq(applicationContext), any()) + val authorizationError = recorded + .filterIsInstance() + .firstOrNull { it.exception is AuthException.UnknownException } + assertThat(authorizationError).isNotNull() + assertThat(authorizationError!!.exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt index 644324d47b..72fbbe5e89 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProviderFirebaseAuthUITest.kt @@ -23,6 +23,8 @@ import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat @@ -32,6 +34,7 @@ import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseAuthWebException import com.google.firebase.auth.FirebaseUser @@ -444,4 +447,60 @@ class OAuthProviderFirebaseAuthUITest { assertThat(reportedFailures).isEmpty() } + + // ============================================================================================= + // Error message routing — the configured AuthUIStringProvider, not the device + // ============================================================================================= + + @Test + fun `signInWithProvider - failure message comes from the configured string provider`() = + runTest { + // "Those credentials are not valid", in Japanese. + val localizedMessage = "その認証情報は有効ではありません" + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException( + FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_CREDENTIAL", + "The supplied auth credential is malformed or has expired." + ) + ) + `when`(mockFirebaseAuth.pendingAuthResult).thenReturn(null) + `when`(mockFirebaseAuth.currentUser).thenReturn(null) + `when`( + mockFirebaseAuth.startActivityForSignInWithProvider( + any(), + any() + ) + ).thenReturn(taskCompletionSource.task) + + val githubProvider = AuthProvider.Github(customParameters = emptyMap()) + val config = authUIConfiguration { + context = applicationContext + providers { provider(githubProvider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorInvalidCredentials: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(config).signInWithProvider( + applicationContext, + activity = mockActivity, + provider = githubProvider + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt index 11f87f249a..efcd3e6301 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProviderFirebaseAuthUITest.kt @@ -23,12 +23,15 @@ import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorSession import com.google.firebase.auth.PhoneAuthCredential @@ -406,6 +409,75 @@ class PhoneAuthProviderFirebaseAuthUITest { .isNotInstanceOf(AuthState.Error::class.java) } + @Test + fun `verifyPhoneNumber - failure message comes from the configured string provider`() = + runTest { + // "The format of the phone number is incorrect", in Japanese. + val localizedMessage = "電話番号の形式が正しくありません" + val localizedConfig = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ) + ) + } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorInvalidCredentials: String = localizedMessage + } + } + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + ) + val rejectingVerifier = object : AuthProvider.Phone.Verifier { + override fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): Flow = flow { + throw FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_PHONE_NUMBER", + "The format of the phone number provided is incorrect." + ) + } + } + + var thrown: Throwable? = null + try { + instance.flowScope(localizedConfig).verifyPhoneNumber( + provider = phoneProvider, + activity = null, + phoneNumber = "not-a-number", + verifier = rejectingVerifier + ) + } catch (t: Throwable) { + thrown = t + } + + // Building the exception without the configured provider leaves Firebase's own English + // message on it, and the error dialog renders that verbatim. + assertThat(thrown).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(thrown).hasMessageThat() + .isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } + @Test fun `verifyPhoneNumber - cancellation does not clobber a newer unrelated state`() = runTest { val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) @@ -607,4 +679,55 @@ class PhoneAuthProviderFirebaseAuthUITest { verify(anonymousUser).linkWithCredential(mockCredential) } + @Test + fun `submitVerificationCode - failure message comes from the configured string provider`() = + runTest { + // "The verification code is incorrect", in Japanese. + val localizedMessage = "確認コードが正しくありません" + // Raised while building the credential, which is submitVerificationCode's own work: + // everything after it is delegated to signInAndLinkWithCredential. + `when`(mockPhoneAuthCredentialProvider.getCredential("test-verification-id", "000000")) + .thenAnswer { + throw FirebaseAuthInvalidCredentialsException( + "ERROR_INVALID_VERIFICATION_CODE", + "The sms verification code used to create the phone auth credential is invalid." + ) + } + + val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + ) + val localizedConfig = authUIConfiguration { + context = applicationContext + providers { provider(phoneProvider) } + stringProvider = object : + AuthUIStringProvider by DefaultAuthUIStringProvider(applicationContext) { + override val errorInvalidCredentials: String = localizedMessage + } + } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + var thrown: Throwable? = null + try { + instance.flowScope(localizedConfig).submitVerificationCode( + applicationContext, + verificationId = "test-verification-id", + code = "000000", + credentialProvider = mockPhoneAuthCredentialProvider + ) + } catch (t: Throwable) { + thrown = t + } + + assertThat(thrown).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(thrown).hasMessageThat().isEqualTo(localizedMessage) + + val state = instance.authStateFlow().first() + assertThat(state).isInstanceOf(AuthState.Error::class.java) + assertThat((state as AuthState.Error).exception).hasMessageThat() + .isEqualTo(localizedMessage) + } } \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt index 60fa0ba406..902cade0bb 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/TopLevelDialogControllerTest.kt @@ -1,5 +1,11 @@ package com.firebase.ui.auth.ui.components +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -7,6 +13,7 @@ import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -39,8 +46,10 @@ class TopLevelDialogControllerTest { lateinit var controller: TopLevelDialogController composeTestRule.setContent { - controller = rememberTopLevelDialogController(stringProvider) { state } - controller.CurrentDialog() + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + controller = rememberTopLevelDialogController { state } + controller.CurrentDialog() + } } val error = AuthState.Error(Exception("boom")) @@ -69,8 +78,10 @@ class TopLevelDialogControllerTest { lateinit var controller: TopLevelDialogController composeTestRule.setContent { - controller = rememberTopLevelDialogController(stringProvider) { state } - controller.CurrentDialog() + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + controller = rememberTopLevelDialogController { state } + controller.CurrentDialog() + } } val error = AuthState.Error(Exception("boom")) @@ -97,6 +108,53 @@ class TopLevelDialogControllerTest { composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist() } + @Test + fun `de-dup fallback reads the latest authState lambda, not the first composition's`() { + stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) + val liveState = mutableStateOf(AuthState.Idle) + lateinit var controller: TopLevelDialogController + + composeTestRule.setContent { + // Mirrors FirebaseAuthScreen, which reads the collected state into a local `val` and + // passes `{ authState }`: every recomposition hands the factory a *new* lambda that + // has captured that frame's value, so an unkeyed `remember` would pin the first one. + val authState = liveState.value + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + controller = rememberTopLevelDialogController { authState } + controller.CurrentDialog() + } + } + + val error = AuthState.Error(Exception("boom")) + val exception = AuthException.from(error.exception, stringProvider) + + // Recompose with the Error before showing anything, so the first composition's captured + // value (Idle) and the live one differ. + composeTestRule.runOnIdle { liveState.value = error } + composeTestRule.waitForIdle() + + // No errorState argument, so `currentAuthState()` is the only path that can record the + // Error for de-duplication. + composeTestRule.runOnIdle { + controller.showErrorDialog(exception = exception) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + composeTestRule.runOnIdle { controller.dismissDialog() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist() + + // Same Error still live, same exception instance: the fallback must have recorded it, so + // this repeat is a no-op. With a pinned first-composition lambda the fallback resolves to + // Idle, records nothing, and the dialog comes back. + composeTestRule.runOnIdle { + controller.showErrorDialog(exception = exception) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertDoesNotExist() + } + @Test fun `second observer of the same error does not overwrite the first observer's dialog`() { stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) @@ -104,8 +162,10 @@ class TopLevelDialogControllerTest { lateinit var controller: TopLevelDialogController composeTestRule.setContent { - controller = rememberTopLevelDialogController(stringProvider) { state } - controller.CurrentDialog() + CompositionLocalProvider(LocalAuthUIStringProvider provides stringProvider) { + controller = rememberTopLevelDialogController { state } + controller.CurrentDialog() + } } val error = AuthState.Error(Exception("boom")) @@ -150,4 +210,66 @@ class TopLevelDialogControllerTest { "secondOnRetryCalled=$secondOnRetryCalled)" } } + + @Test + fun `dialog survives a host that rebuilds its string provider every recomposition`() { + val context = ApplicationProvider.getApplicationContext() + stringProvider = DefaultAuthUIStringProvider(context) + var state: AuthState = AuthState.Idle + lateinit var controller: TopLevelDialogController + var tick by mutableIntStateOf(0) + + composeTestRule.setContent { + // What `authUIConfiguration { }` built inside a composable does: a fresh + // DefaultAuthUIStringProvider, identity-equal to nothing, on every recomposition. + @Suppress("UNUSED_EXPRESSION") + tick + val unstableProvider = DefaultAuthUIStringProvider(context) + CompositionLocalProvider(LocalAuthUIStringProvider provides unstableProvider) { + controller = rememberTopLevelDialogController { state } + controller.CurrentDialog() + } + } + + val error = AuthState.Error(Exception("boom")) + composeTestRule.runOnIdle { + state = error + controller.showErrorDialog( + exception = AuthException.from(error.exception, stringProvider) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + + composeTestRule.runOnIdle { tick++ } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + } + + @Suppress("DEPRECATION") + @Test + fun `deprecated constructor still renders with its explicit provider and no CompositionLocal`() { + stringProvider = DefaultAuthUIStringProvider(ApplicationProvider.getApplicationContext()) + var state: AuthState = AuthState.Idle + lateinit var controller: TopLevelDialogController + + // Deliberately no LocalAuthUIStringProvider in scope: the local throws when absent, so + // this pins that the deprecated path keeps honouring the provider it was handed. + composeTestRule.setContent { + controller = rememberTopLevelDialogController(stringProvider) { state } + controller.CurrentDialog() + } + + val error = AuthState.Error(Exception("boom")) + composeTestRule.runOnIdle { + state = error + controller.showErrorDialog( + exception = AuthException.from(error.exception, stringProvider) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(stringProvider.errorDialogTitle).assertExists() + } } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthRouteNavigationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthRouteNavigationTest.kt index 7d3abe4c62..c0552f9990 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthRouteNavigationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/email/EmailAuthRouteNavigationTest.kt @@ -657,7 +657,7 @@ class EmailAuthRouteNavigationTest { } val authState by remember(authUI) { authUI.authStateFlow() }.collectAsState(AuthState.Idle) - val dialogController = rememberTopLevelDialogController(stringProvider) { authState } + val dialogController = rememberTopLevelDialogController { authState } CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt index 9624b2d47e..f27c81110f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/phone/PhoneAuthScreenVerificationLifecycleTest.kt @@ -172,7 +172,7 @@ class PhoneAuthScreenVerificationLifecycleTest { */ private fun setScreenContent(withDialogs: Boolean = false) { composeTestRule.setContent { - val controller = rememberTopLevelDialogController(configuration.stringProvider) { + val controller = rememberTopLevelDialogController { AuthState.Idle } CompositionLocalProvider( @@ -202,8 +202,10 @@ class PhoneAuthScreenVerificationLifecycleTest { flowState = flowState, ) { state -> capturedState = state } } + // Inside the provider, like FirebaseAuthScreen: CurrentDialog resolves its + // strings from LocalAuthUIStringProvider at render time. + if (withDialogs) controller.CurrentDialog() } - if (withDialogs) controller.CurrentDialog() } composeTestRule.waitForIdle() }