From 96506d95eacb77521ff10e08ccddc377612153ea Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 11:20:32 +0100 Subject: [PATCH 1/7] feat(auth): implement legacyFetchSignInWithEmail configuration option --- .../com/firebase/ui/auth/AuthException.kt | 20 +++++ .../auth/configuration/AuthUIConfiguration.kt | 11 +++ .../EmailAuthProvider+FirebaseAuthUI.kt | 73 ++++++++++++++++++- .../auth/ui/components/ErrorRecoveryDialog.kt | 30 ++++++++ .../ui/components/TopLevelDialogController.kt | 12 +-- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 35 ++++++--- .../auth/ui/screens/email/EmailAuthScreen.kt | 16 ++++ 7 files changed, 181 insertions(+), 16 deletions(-) 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 46d22f068..e88c5c1ae 100644 --- a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt @@ -200,6 +200,26 @@ abstract class AuthException( cause: Throwable? = null ) : AuthException(message, cause) + /** + * A different sign-in method should be used for this email address. + * + * This exception is used for the opt-in legacy recovery path backed by + * `fetchSignInMethodsForEmail`, allowing the UI to guide users toward a previously + * used provider when email enumeration protection has been disabled. + * + * @property email The email address being recovered + * @property signInMethods The sign-in methods returned by Firebase Auth + * @property suggestedSignInMethod The preferred method the UI should direct the user toward + * @property cause The underlying authentication failure that triggered the lookup + */ + class DifferentSignInMethodRequiredException( + message: String, + val email: String, + val signInMethods: List, + val suggestedSignInMethod: String, + cause: Throwable? = null + ) : AuthException(message, cause) + /** * Authentication was cancelled by the user. * diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt index 3fa7f394b..a43c436fe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt @@ -49,6 +49,7 @@ class AuthUIConfigurationBuilder { var isNewEmailAccountsAllowed: Boolean = true var isDisplayNameRequired: Boolean = true var isProviderChoiceAlwaysShown: Boolean = false + var legacyFetchSignInWithEmail: Boolean = false var transitions: AuthUITransitions? = null fun providers(block: AuthProvidersBuilder.() -> Unit) = @@ -114,6 +115,7 @@ class AuthUIConfigurationBuilder { isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, isDisplayNameRequired = isDisplayNameRequired, isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown, + legacyFetchSignInWithEmail = legacyFetchSignInWithEmail, transitions = transitions ) } @@ -199,6 +201,15 @@ class AuthUIConfiguration( */ val isProviderChoiceAlwaysShown: Boolean = false, + /** + * Enables legacy provider recovery via `fetchSignInMethodsForEmail`. + * + * This should only be enabled when email enumeration protection is disabled for the + * Firebase project and the application explicitly wants to use the legacy API to + * recover from email/password attempts made with the wrong provider. + */ + val legacyFetchSignInWithEmail: Boolean = false, + /** * Custom screen transition animations. * If null, uses default fade in/out transitions. 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 8d4bae6d1..b76246c7d 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 @@ -40,6 +40,7 @@ import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthMultiFactorException import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.SignInMethodQueryResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.tasks.await @@ -450,12 +451,82 @@ internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( updateAuthState(AuthState.Error(e)) throw e } catch (e: Exception) { - val authException = AuthException.from(e) + val authException = recoverLegacyDifferentSignInMethod(config, email, e) + ?: AuthException.from(e) updateAuthState(AuthState.Error(authException)) throw authException } } +private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod( + config: AuthUIConfiguration, + email: String, + cause: Exception, +): AuthException.DifferentSignInMethodRequiredException? { + if (!config.legacyFetchSignInWithEmail) { + return null + } + + val authException = AuthException.from(cause) + if (authException !is AuthException.InvalidCredentialsException && + authException !is AuthException.UserNotFoundException) { + return null + } + + val signInMethods = fetchLegacySignInMethods(email) + val suggestedSignInMethod = selectSuggestedLegacySignInMethod(config, signInMethods) ?: return null + if (signInMethods.isEmpty()) { + return null + } + + return AuthException.DifferentSignInMethodRequiredException( + message = config.stringProvider.accountLinkingRequiredRecoveryMessage, + email = email, + signInMethods = signInMethods, + suggestedSignInMethod = suggestedSignInMethod, + cause = cause + ) +} + +private fun selectSuggestedLegacySignInMethod( + config: AuthUIConfiguration, + signInMethods: List, +): String? { + if (signInMethods.isEmpty() || + EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD in signInMethods) { + return null + } + + val emailProvider = config.providers.filterIsInstance().firstOrNull() + val configuredProviderIds = config.providers.map { it.providerId }.toSet() + + return signInMethods.firstOrNull { signInMethod -> + when { + signInMethod == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> { + emailProvider?.isEmailLinkSignInEnabled == true + } + + signInMethod == EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD -> false + else -> signInMethod in configuredProviderIds + } + } +} + +private suspend fun FirebaseAuthUI.fetchLegacySignInMethods(email: String): List { + return try { + @Suppress("DEPRECATION") + auth.fetchSignInMethodsForEmail(email) + .await() + .toSignInMethods() + } catch (fetchException: Exception) { + Log.w(TAG, "Legacy fetchSignInMethodsForEmail failed for: $email", fetchException) + emptyList() + } +} + +private fun SignInMethodQueryResult?.toSignInMethods(): List = + this?.signInMethods?.filter { it.isNotBlank() } ?: emptyList() + /** * Signs in with a credential or links it to an existing anonymous user. * diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt index dff4daa60..ad901f345 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt @@ -23,6 +23,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties import com.firebase.ui.auth.AuthException +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GithubAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.TwitterAuthProvider import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider /** @@ -158,6 +164,9 @@ private fun getRecoveryMessage( // Use the custom message which includes email and provider details error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage } + is AuthException.DifferentSignInMethodRequiredException -> { + error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage + } is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage @@ -192,6 +201,8 @@ private fun getRecoveryActionText( is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts + is AuthException.DifferentSignInMethodRequiredException -> + getDifferentSignInMethodActionText(error.suggestedSignInMethod, stringProvider) is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText @@ -226,6 +237,7 @@ private fun isRecoverable(error: AuthException): Boolean { is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown is AuthException.MfaRequiredException -> true is AuthException.AccountLinkingRequiredException -> true + is AuthException.DifferentSignInMethodRequiredException -> true is AuthException.AuthCancelledException -> true is AuthException.EmailLinkPromptForEmailException -> true is AuthException.EmailLinkCrossDeviceLinkingException -> true @@ -235,3 +247,21 @@ private fun isRecoverable(error: AuthException): Boolean { else -> true } } + +private fun getDifferentSignInMethodActionText( + signInMethod: String, + stringProvider: AuthUIStringProvider, +): String { + return when (signInMethod) { + GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle + FacebookAuthProvider.PROVIDER_ID -> stringProvider.continueWithFacebook + TwitterAuthProvider.PROVIDER_ID -> stringProvider.continueWithTwitter + GithubAuthProvider.PROVIDER_ID -> stringProvider.continueWithGithub + PhoneAuthProvider.PROVIDER_ID -> stringProvider.continueWithPhone + "apple.com" -> stringProvider.continueWithApple + "microsoft.com" -> stringProvider.continueWithMicrosoft + "yahoo.com" -> stringProvider.continueWithYahoo + EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink + else -> stringProvider.continueText + } +} 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 626c56c04..4cd0aadd8 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 @@ -85,7 +85,7 @@ class TopLevelDialogController( fun showErrorDialog( exception: AuthException, onRetry: (AuthException) -> Unit = {}, - onRecover: (AuthException) -> Unit = {}, + onRecover: ((AuthException) -> Unit)? = null, onDismiss: () -> Unit = {} ) { // Get current error state @@ -135,9 +135,11 @@ class TopLevelDialogController( state.onRetry(exception) state.onDismiss() }, - onRecover = { exception -> - state.onRecover(exception) - state.onDismiss() + onRecover = state.onRecover?.let { onRecover -> + { exception -> + onRecover(exception) + state.onDismiss() + } }, onDismiss = state.onDismiss ) @@ -152,7 +154,7 @@ class TopLevelDialogController( data class ErrorDialog( val exception: AuthException, val onRetry: (AuthException) -> Unit, - val onRecover: (AuthException) -> Unit, + val onRecover: ((AuthException) -> Unit)?, val onDismiss: () -> Unit ) : DialogState() } 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 fbf0bed2b..529561fff 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 @@ -318,6 +318,17 @@ fun FirebaseAuthScreen( authUI = authUI, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, + onContinueWithProvider = { providerId -> + when (providerId) { + googleProvider?.providerId -> onSignInWithGoogle?.invoke() + facebookProvider?.providerId -> onSignInWithFacebook?.invoke() + appleProvider?.providerId -> onSignInWithApple?.invoke() + githubProvider?.providerId -> onSignInWithGithub?.invoke() + microsoftProvider?.providerId -> onSignInWithMicrosoft?.invoke() + yahooProvider?.providerId -> onSignInWithYahoo?.invoke() + twitterProvider?.providerId -> onSignInWithTwitter?.invoke() + } + }, onSuccess = { pendingLinkingCredential.value = null }, @@ -617,39 +628,43 @@ fun FirebaseAuthScreen( onRetry = { _ -> // Child screens handle their own retry logic }, - onRecover = { exception -> - when (exception) { - is AuthException.EmailAlreadyInUseException -> { + onRecover = when (exception) { + is AuthException.EmailAlreadyInUseException -> { + { navController.navigate(AuthRoute.Email.route) { launchSingleTop = true } } + } - is AuthException.AccountLinkingRequiredException -> { + is AuthException.AccountLinkingRequiredException -> { + { pendingLinkingCredential.value = exception.credential navController.navigate(AuthRoute.Email.route) { launchSingleTop = true } } + } - is AuthException.EmailLinkPromptForEmailException -> { - // Cross-device flow: User needs to enter their email + is AuthException.EmailLinkPromptForEmailException -> { + { emailLinkFromDifferentDevice.value = exception.emailLink navController.navigate(AuthRoute.Email.route) { launchSingleTop = true } } + } - is AuthException.EmailLinkCrossDeviceLinkingException -> { - // Cross-device linking flow: User needs to enter email to link provider + is AuthException.EmailLinkCrossDeviceLinkingException -> { + { emailLinkFromDifferentDevice.value = exception.emailLink navController.navigate(AuthRoute.Email.route) { launchSingleTop = true } } - - else -> Unit } + + else -> null }, onDismiss = { // Dialog dismissed diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt index 2ebc2542f..70ef570eb 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/EmailAuthScreen.kt @@ -42,6 +42,7 @@ import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundExceptio import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider import kotlinx.coroutines.launch enum class EmailAuthMode { @@ -130,6 +131,7 @@ fun EmailAuthScreen( authUI: FirebaseAuthUI, credentialForLinking: AuthCredential? = null, emailLinkFromDifferentDevice: String? = null, + onContinueWithProvider: (String) -> Unit = {}, onSuccess: (AuthResult) -> Unit, onError: (AuthException) -> Unit, onCancel: () -> Unit, @@ -209,6 +211,20 @@ fun EmailAuthScreen( else -> Unit } }, + onRecover = if (exception is AuthException.DifferentSignInMethodRequiredException) { + { ex -> + val differentProviderException = + ex as AuthException.DifferentSignInMethodRequiredException + if (differentProviderException.suggestedSignInMethod == + EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) { + mode.value = EmailAuthMode.EmailLinkSignIn + } else { + onContinueWithProvider(differentProviderException.suggestedSignInMethod) + } + } + } else { + null + }, onDismiss = { // Dialog dismissed } From c7cc486661c822f27306ba999ac35d2ee9cbb542 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 11:21:09 +0100 Subject: [PATCH 2/7] test: write tests for legacyFetchSignInWithEmail --- .../configuration/AuthUIConfigurationTest.kt | 4 + .../EmailAuthProviderFirebaseAuthUITest.kt | 151 ++++++++++++++++++ .../ErrorRecoveryDialogLogicTest.kt | 42 ++++- 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt index 4afcfa84b..3f7f20b9f 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/AuthUIConfigurationTest.kt @@ -88,6 +88,7 @@ class AuthUIConfigurationTest { assertThat(config.isNewEmailAccountsAllowed).isTrue() assertThat(config.isDisplayNameRequired).isTrue() assertThat(config.isProviderChoiceAlwaysShown).isFalse() + assertThat(config.legacyFetchSignInWithEmail).isFalse() } @Test @@ -129,6 +130,7 @@ class AuthUIConfigurationTest { isNewEmailAccountsAllowed = false isDisplayNameRequired = false isProviderChoiceAlwaysShown = true + legacyFetchSignInWithEmail = true } assertThat(config.context).isEqualTo(applicationContext) @@ -147,6 +149,7 @@ class AuthUIConfigurationTest { assertThat(config.isNewEmailAccountsAllowed).isFalse() assertThat(config.isDisplayNameRequired).isFalse() assertThat(config.isProviderChoiceAlwaysShown).isTrue() + assertThat(config.legacyFetchSignInWithEmail).isTrue() } @Test @@ -465,6 +468,7 @@ class AuthUIConfigurationTest { "isNewEmailAccountsAllowed", "isDisplayNameRequired", "isProviderChoiceAlwaysShown", + "legacyFetchSignInWithEmail", "transitions" ) 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 dc027e3dc..72d90fddf 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 @@ -37,6 +37,7 @@ import com.google.firebase.auth.FirebaseAuthInvalidUserException import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.SignInMethodQueryResult import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -413,6 +414,156 @@ class EmailAuthProviderFirebaseAuthUITest { } } + @Test + fun `signInWithEmailAndPassword - uses legacy provider recovery when configured`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val signInTask = TaskCompletionSource() + signInTask.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + + val queryResult = mock(SignInMethodQueryResult::class.java) + `when`(queryResult.signInMethods).thenReturn(listOf(GoogleAuthProvider.PROVIDER_ID)) + val fetchTask = TaskCompletionSource() + fetchTask.setResult(queryResult) + `when`(mockFirebaseAuth.fetchSignInMethodsForEmail("test@example.com")) + .thenReturn(fetchTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + provider( + AuthProvider.Google( + scopes = emptyList(), + serverClientId = "test-client-id" + ) + ) + } + legacyFetchSignInWithEmail = true + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() + } catch (e: AuthException.DifferentSignInMethodRequiredException) { + assertThat(e.email).isEqualTo("test@example.com") + assertThat(e.signInMethods).containsExactly(GoogleAuthProvider.PROVIDER_ID) + assertThat(e.suggestedSignInMethod).isEqualTo(GoogleAuthProvider.PROVIDER_ID) + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + + val currentState = instance.authStateFlow().first { it is AuthState.Error } + val errorState = currentState as AuthState.Error + assertThat(errorState.exception) + .isInstanceOf(AuthException.DifferentSignInMethodRequiredException::class.java) + verify(mockFirebaseAuth).fetchSignInMethodsForEmail("test@example.com") + } + + @Test + fun `signInWithEmailAndPassword - keeps invalid credentials when password sign in is available`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val signInTask = TaskCompletionSource() + signInTask.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + + val queryResult = mock(SignInMethodQueryResult::class.java) + `when`(queryResult.signInMethods) + .thenReturn(listOf(com.google.firebase.auth.EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD)) + val fetchTask = TaskCompletionSource() + fetchTask.setResult(queryResult) + `when`(mockFirebaseAuth.fetchSignInMethodsForEmail("test@example.com")) + .thenReturn(fetchTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + } + legacyFetchSignInWithEmail = true + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + + verify(mockFirebaseAuth).fetchSignInMethodsForEmail("test@example.com") + } + + @Test + fun `signInWithEmailAndPassword - does not use legacy provider recovery when disabled`() = runTest { + val invalidCredentialsException = FirebaseAuthInvalidCredentialsException( + "ERROR_WRONG_PASSWORD", + "Wrong password" + ) + val signInTask = TaskCompletionSource() + signInTask.setException(invalidCredentialsException) + `when`(mockFirebaseAuth.signInWithEmailAndPassword("test@example.com", "Pass@123")) + .thenReturn(signInTask.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val emailProvider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(emailProvider) + provider( + AuthProvider.Google( + scopes = emptyList(), + serverClientId = "test-client-id" + ) + ) + } + } + + try { + instance.signInWithEmailAndPassword( + context = applicationContext, + config = config, + email = "test@example.com", + password = "Pass@123" + ) + assertThat(false).isTrue() + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.cause).isEqualTo(invalidCredentialsException) + } + + verify(mockFirebaseAuth, never()).fetchSignInMethodsForEmail(anyString()) + } + @Test fun `signInWithEmailAndPassword - handles user not found`() = runTest { val userNotFoundException = FirebaseAuthInvalidUserException( diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt index ca53181eb..05d86eecd 100644 --- a/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialogLogicTest.kt @@ -3,6 +3,8 @@ package com.firebase.ui.auth.ui.components import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider import com.google.common.truth.Truth +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.GoogleAuthProvider import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito @@ -20,6 +22,8 @@ class ErrorRecoveryDialogLogicTest { Mockito.`when`(retryAction).thenReturn("Try again") Mockito.`when`(continueText).thenReturn("Continue") Mockito.`when`(signInDefault).thenReturn("Sign in") + Mockito.`when`(continueWithGoogle).thenReturn("Continue with Google") + Mockito.`when`(signInWithEmailLink).thenReturn("Sign in with email link") Mockito.`when`(networkErrorRecoveryMessage).thenReturn("Network error, check your internet connection.") Mockito.`when`(invalidCredentialsRecoveryMessage).thenReturn("Incorrect password.") Mockito.`when`(userNotFoundRecoveryMessage).thenReturn("That email address doesn't match an existing account") @@ -216,6 +220,34 @@ class ErrorRecoveryDialogLogicTest { Truth.assertThat(actionText).isEqualTo("Continue") } + @Test + fun `getRecoveryActionText returns provider specific action for DifferentSignInMethodRequiredException`() { + val error = AuthException.DifferentSignInMethodRequiredException( + message = "Use a different sign-in method", + email = "test@example.com", + signInMethods = listOf(GoogleAuthProvider.PROVIDER_ID), + suggestedSignInMethod = GoogleAuthProvider.PROVIDER_ID + ) + + val actionText = getRecoveryActionText(error, mockStringProvider) + + Truth.assertThat(actionText).isEqualTo("Continue with Google") + } + + @Test + fun `getRecoveryActionText returns email link action for DifferentSignInMethodRequiredException`() { + val error = AuthException.DifferentSignInMethodRequiredException( + message = "Use a different sign-in method", + email = "test@example.com", + signInMethods = listOf(EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD), + suggestedSignInMethod = EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD + ) + + val actionText = getRecoveryActionText(error, mockStringProvider) + + Truth.assertThat(actionText).isEqualTo("Sign in with email link") + } + @Test fun `getRecoveryActionText returns continue for MfaRequiredException`() { // Arrange @@ -302,6 +334,8 @@ class ErrorRecoveryDialogLogicTest { is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage is AuthException.AccountLinkingRequiredException -> stringProvider.accountLinkingRequiredRecoveryMessage + is AuthException.DifferentSignInMethodRequiredException -> + error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage is AuthException.UnknownException -> stringProvider.unknownErrorRecoveryMessage else -> stringProvider.unknownErrorRecoveryMessage @@ -313,6 +347,11 @@ class ErrorRecoveryDialogLogicTest { is AuthException.AuthCancelledException -> stringProvider.continueText is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault is AuthException.AccountLinkingRequiredException -> stringProvider.continueText + is AuthException.DifferentSignInMethodRequiredException -> when (error.suggestedSignInMethod) { + GoogleAuthProvider.PROVIDER_ID -> stringProvider.continueWithGoogle + EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD -> stringProvider.signInWithEmailLink + else -> stringProvider.continueText + } is AuthException.MfaRequiredException -> stringProvider.continueText is AuthException.NetworkException, is AuthException.InvalidCredentialsException, @@ -334,9 +373,10 @@ class ErrorRecoveryDialogLogicTest { is AuthException.TooManyRequestsException -> false is AuthException.MfaRequiredException -> true is AuthException.AccountLinkingRequiredException -> true + is AuthException.DifferentSignInMethodRequiredException -> true is AuthException.AuthCancelledException -> true is AuthException.UnknownException -> true else -> true } } -} \ No newline at end of file +} From ed1d89a19e1fb738aff4351b652d711a95a9b765 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 11:43:43 +0100 Subject: [PATCH 3/7] fix(pr): address reviewer feedback on email recovery routing --- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) 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 529561fff..929342788 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 @@ -82,6 +82,7 @@ import com.firebase.ui.auth.util.SignInPreferenceManager import com.firebase.ui.auth.util.displayIdentifier import com.firebase.ui.auth.util.getDisplayEmail import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorResolver import kotlinx.coroutines.launch @@ -231,6 +232,21 @@ fun FirebaseAuthScreen( provider = it ) } + val continueWithProvider: (String) -> Unit = { providerId -> + when (providerId) { + googleProvider?.providerId -> onSignInWithGoogle?.invoke() + facebookProvider?.providerId -> onSignInWithFacebook?.invoke() + appleProvider?.providerId -> onSignInWithApple?.invoke() + githubProvider?.providerId -> onSignInWithGithub?.invoke() + microsoftProvider?.providerId -> onSignInWithMicrosoft?.invoke() + yahooProvider?.providerId -> onSignInWithYahoo?.invoke() + twitterProvider?.providerId -> onSignInWithTwitter?.invoke() + else -> genericOAuthHandlers.entries + .find { it.key.providerId == providerId } + ?.value + ?.invoke() + } + } CompositionLocalProvider( LocalAuthUIStringProvider provides configuration.stringProvider, @@ -318,17 +334,7 @@ fun FirebaseAuthScreen( authUI = authUI, credentialForLinking = pendingLinkingCredential.value, emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, - onContinueWithProvider = { providerId -> - when (providerId) { - googleProvider?.providerId -> onSignInWithGoogle?.invoke() - facebookProvider?.providerId -> onSignInWithFacebook?.invoke() - appleProvider?.providerId -> onSignInWithApple?.invoke() - githubProvider?.providerId -> onSignInWithGithub?.invoke() - microsoftProvider?.providerId -> onSignInWithMicrosoft?.invoke() - yahooProvider?.providerId -> onSignInWithYahoo?.invoke() - twitterProvider?.providerId -> onSignInWithTwitter?.invoke() - } - }, + onContinueWithProvider = continueWithProvider, onSuccess = { pendingLinkingCredential.value = null }, @@ -664,6 +670,19 @@ fun FirebaseAuthScreen( } } + is AuthException.DifferentSignInMethodRequiredException -> { + { + val providerId = exception.suggestedSignInMethod + if (providerId == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) { + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } else { + continueWithProvider(providerId) + } + } + } + else -> null }, onDismiss = { From d533fcf4e0089f4e02af950a5f6d1bfe38c89160 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 11:45:00 +0100 Subject: [PATCH 4/7] refactor(pr): address reviewer feedback on legacy email recovery --- .../auth_provider/EmailAuthProvider+FirebaseAuthUI.kt | 3 --- 1 file changed, 3 deletions(-) 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 b76246c7d..0557a49dd 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 @@ -475,9 +475,6 @@ private suspend fun FirebaseAuthUI.recoverLegacyDifferentSignInMethod( val signInMethods = fetchLegacySignInMethods(email) val suggestedSignInMethod = selectSuggestedLegacySignInMethod(config, signInMethods) ?: return null - if (signInMethods.isEmpty()) { - return null - } return AuthException.DifferentSignInMethodRequiredException( message = config.stringProvider.accountLinkingRequiredRecoveryMessage, From 2f6932827d8b700297797d89b148be97095010b5 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 12:37:12 +0100 Subject: [PATCH 5/7] fix: cancellation callback + test --- .../ui/auth/ui/screens/FirebaseAuthScreen.kt | 10 +- .../FirebaseAuthScreenCancellationTest.kt | 137 ++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt 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 929342788..876edea87 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 @@ -343,9 +343,7 @@ fun FirebaseAuthScreen( }, onCancel = { pendingLinkingCredential.value = null - if (skipsMethodPicker) { - onSignInCancelled() - } else if (!navController.popBackStack()) { + if (!skipsMethodPicker && !navController.popBackStack()) { navController.navigate(AuthRoute.MethodPicker.route) { popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } launchSingleTop = true @@ -365,9 +363,7 @@ fun FirebaseAuthScreen( onSignInFailure(exception) }, onCancel = { - if (skipsMethodPicker) { - onSignInCancelled() - } else if (!navController.popBackStack()) { + if (!skipsMethodPicker && !navController.popBackStack()) { navController.navigate(AuthRoute.MethodPicker.route) { popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } launchSingleTop = true @@ -601,6 +597,8 @@ fun FirebaseAuthScreen( launchSingleTop = true } } + // Keep external cancellation reporting centralized here so child screens + // can handle local navigation without triggering duplicate callbacks. onSignInCancelled() } diff --git a/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt new file mode 100644 index 000000000..e5f1e9e7c --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreenCancellationTest.kt @@ -0,0 +1,137 @@ +package com.firebase.ui.auth.ui.screens + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +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 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +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 +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthScreenCancellationTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + private lateinit var authUI: FirebaseAuthUI + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> + app.delete() + } + + val defaultApp = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + )!! + + `when`(mockFirebaseAuth.app).thenReturn(defaultApp) + + authUI = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> + app.delete() + } + } + + @Test + fun `single email provider cancellation invokes callback once`() { + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + } + } + var cancelCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelCount++ } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Cancelled) + } + composeTestRule.waitForIdle() + + assertThat(cancelCount).isEqualTo(1) + } + + @Test + fun `single phone provider cancellation invokes callback once`() { + val configuration = authUIConfiguration { + context = ApplicationProvider.getApplicationContext() + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + var cancelCount = 0 + + composeTestRule.setContent { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = {}, + onSignInFailure = {}, + onSignInCancelled = { cancelCount++ } + ) + } + + composeTestRule.runOnIdle { + authUI.updateAuthState(AuthState.Cancelled) + } + composeTestRule.waitForIdle() + + assertThat(cancelCount).isEqualTo(1) + } +} From 7318ad5d5fbd03b1b25e331fe238226fb4b01c9c Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 12:46:25 +0100 Subject: [PATCH 6/7] chore: ignore kotlin cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ca0f434ff..1582a5ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ google-services.json crashlytics-build.properties auth/src/main/res/values/com_crashlytics_export_strings.xml *.log +.kotlin/ \ No newline at end of file From 2cab2fce5c73723c2d76756c3db5aa39d4ad1509 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 20 Apr 2026 13:05:42 +0100 Subject: [PATCH 7/7] chore: run demo app script from command line --- app/scripts/run-demo.sh | 259 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100755 app/scripts/run-demo.sh diff --git a/app/scripts/run-demo.sh b/app/scripts/run-demo.sh new file mode 100755 index 000000000..5f6f8325d --- /dev/null +++ b/app/scripts/run-demo.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash + +set -euo pipefail + +APP_ID="com.firebaseui.android.demo" +MAIN_ACTIVITY="com.firebaseui.android.demo.MainActivity" +APK_RELATIVE_PATH="app/build/outputs/apk/debug/app-debug.apk" +EMULATOR_LOG="${TMPDIR:-/tmp}/firebaseui-android-emulator.log" +EMULATOR_PID="" +LAUNCHED_EMULATOR=0 +CONNECTED_DEVICES=() +AVAILABLE_AVDS=() +KNOWN_DEVICE_SERIALS=() + +usage() { + cat <<'EOF' +Usage: run-demo.sh [--help] + +Builds, installs, and launches the FirebaseUI Android demo app. + +The script lets you: +1. Use an already connected Android device or emulator. +2. Start an AVD selected from `emulator -list-avds`. +EOF +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Missing required command: $command_name" >&2 + exit 1 + fi +} + +collect_connected_devices() { + CONNECTED_DEVICES=() + while IFS= read -r serial; do + if [[ -n "$serial" ]]; then + CONNECTED_DEVICES+=("$serial") + fi + done < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') +} + +collect_available_avds() { + AVAILABLE_AVDS=() + while IFS= read -r avd_name; do + if [[ -n "$avd_name" ]]; then + AVAILABLE_AVDS+=("$avd_name") + fi + done < <(emulator -list-avds 2>/dev/null) +} + +prompt_for_choice() { + local prompt="$1" + shift + local options=("$@") + local selection + local index=1 + + echo "$prompt" + for option in "${options[@]}"; do + printf " %d) %s\n" "$index" "$option" + index=$((index + 1)) + done + + while true; do + printf "Select an option [1-%d]: " "${#options[@]}" + read -r selection + + if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#options[@]} )); then + CHOICE_INDEX=$((selection - 1)) + return 0 + fi + + echo "Please enter a number between 1 and ${#options[@]}." + done +} + +is_known_device() { + local candidate="$1" + local known + local index + + for (( index=0; index<${#KNOWN_DEVICE_SERIALS[@]}; index++ )); do + known="${KNOWN_DEVICE_SERIALS[$index]}" + if [[ "$known" == "$candidate" ]]; then + return 0 + fi + done + + return 1 +} + +cleanup_on_exit() { + local exit_code="$?" + + if (( exit_code != 0 )) && (( LAUNCHED_EMULATOR == 1 )) && [[ -n "$EMULATOR_PID" ]]; then + echo "Stopping emulator started by this script..." >&2 + kill "$EMULATOR_PID" 2>/dev/null || true + fi + + exit "$exit_code" +} + +wait_for_device_boot() { + local serial="$1" + local attempt + local boot_completed + + adb -s "$serial" wait-for-device >/dev/null + + echo "Waiting for $serial to finish booting..." + for (( attempt=1; attempt<=120; attempt++ )); do + boot_completed="$(adb -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [[ "$boot_completed" == "1" ]]; then + echo "$serial is ready." + return 0 + fi + sleep 2 + done + + echo "Timed out waiting for $serial to boot." >&2 + exit 1 +} + +start_selected_avd() { + local avd_name="$1" + local attempt + local serial + local index + + collect_connected_devices + KNOWN_DEVICE_SERIALS=() + for (( index=0; index<${#CONNECTED_DEVICES[@]}; index++ )); do + KNOWN_DEVICE_SERIALS+=("${CONNECTED_DEVICES[$index]}") + done + + echo "Starting emulator '$avd_name'..." + echo "Emulator logs: $EMULATOR_LOG" + emulator -avd "$avd_name" >"$EMULATOR_LOG" 2>&1 & + EMULATOR_PID=$! + LAUNCHED_EMULATOR=1 + + for (( attempt=1; attempt<=120; attempt++ )); do + sleep 2 + collect_connected_devices + for (( index=0; index<${#CONNECTED_DEVICES[@]}; index++ )); do + serial="${CONNECTED_DEVICES[$index]}" + case "$serial" in + emulator-*) + if ! is_known_device "$serial"; then + TARGET_SERIAL="$serial" + wait_for_device_boot "$TARGET_SERIAL" + return 0 + fi + ;; + esac + done + done + + echo "Failed to detect the new emulator for AVD '$avd_name'." >&2 + exit 1 +} + +choose_target_device() { + local option_labels=() + local option_types=() + local option_values=() + local serial + local avd_name + local index + + collect_connected_devices + collect_available_avds + + for (( index=0; index<${#CONNECTED_DEVICES[@]}; index++ )); do + serial="${CONNECTED_DEVICES[$index]}" + option_labels+=("Use connected device: $serial") + option_types+=("device") + option_values+=("$serial") + done + + for (( index=0; index<${#AVAILABLE_AVDS[@]}; index++ )); do + avd_name="${AVAILABLE_AVDS[$index]}" + option_labels+=("Start emulator: $avd_name") + option_types+=("avd") + option_values+=("$avd_name") + done + + if (( ${#option_labels[@]} == 0 )); then + cat >&2 <<'EOF' +No connected Android devices were found, and no AVDs are available. +Connect a device or create an emulator first, then rerun this script. +EOF + exit 1 + fi + + prompt_for_choice "Choose a target for the demo app:" "${option_labels[@]}" + + case "${option_types[$CHOICE_INDEX]}" in + device) + TARGET_SERIAL="${option_values[$CHOICE_INDEX]}" + ;; + avd) + start_selected_avd "${option_values[$CHOICE_INDEX]}" + ;; + esac +} + +main() { + local script_dir + local repo_root + local apk_path + local launch_output + + if [[ "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + + trap cleanup_on_exit EXIT + + require_command adb + require_command emulator + + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" + apk_path="$repo_root/$APK_RELATIVE_PATH" + + if [[ ! -x "$repo_root/gradlew" ]]; then + echo "gradlew not found or not executable at $repo_root/gradlew" >&2 + exit 1 + fi + + choose_target_device + + echo "Building debug APK..." + "$repo_root/gradlew" :app:assembleDebug + + if [[ ! -f "$apk_path" ]]; then + echo "APK not found at $apk_path" >&2 + exit 1 + fi + + echo "Installing app on $TARGET_SERIAL..." + adb -s "$TARGET_SERIAL" install -r "$apk_path" + + echo "Launching demo app on $TARGET_SERIAL..." + launch_output="$(adb -s "$TARGET_SERIAL" shell am start -n "$APP_ID/$MAIN_ACTIVITY")" + echo "$launch_output" + if [[ "$launch_output" == *"Error:"* ]]; then + echo "Failed to launch the demo app." >&2 + exit 1 + fi + + trap - EXIT +} + +main "$@"