- * For a general intro to the RecyclerView, see Creating
- * Lists.
- */
-@SuppressLint("RestrictedApi")
-public class RealtimeDbChatActivity extends AppCompatActivity
- implements FirebaseAuth.AuthStateListener {
- private static final String TAG = "RealtimeDatabaseDemo";
-
- /**
- * Get the last 50 chat messages.
- */
- @NonNull
- protected final Query sChatQuery =
- FirebaseDatabase.getInstance().getReference().child("chats").limitToLast(50);
-
- private ActivityChatBinding mBinding;
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- // Enable edge-to-edge
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
-
- mBinding = ActivityChatBinding.inflate(getLayoutInflater());
- setContentView(mBinding.getRoot());
-
- mBinding.messagesList.setHasFixedSize(true);
- mBinding.messagesList.setLayoutManager(new LinearLayoutManager(this));
-
- ImeHelper.setImeOnDoneListener(mBinding.messageEdit, () -> onSendClick());
-
- mBinding.sendButton.setOnClickListener(view -> onSendClick());
- }
-
- @Override
- public void onStart() {
- super.onStart();
- if (isSignedIn()) {
- attachRecyclerViewAdapter();
- }
- FirebaseAuth.getInstance().addAuthStateListener(this);
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- FirebaseAuth.getInstance().removeAuthStateListener(this);
- }
-
- @Override
- public void onAuthStateChanged(@NonNull FirebaseAuth auth) {
- mBinding.sendButton.setEnabled(isSignedIn());
- mBinding.messageEdit.setEnabled(isSignedIn());
-
- if (isSignedIn()) {
- attachRecyclerViewAdapter();
- } else {
- Toast.makeText(this, R.string.signing_in, Toast.LENGTH_SHORT).show();
- auth.signInAnonymously().addOnCompleteListener(new SignInResultNotifier(this));
- }
- }
-
- private boolean isSignedIn() {
- return FirebaseAuth.getInstance().getCurrentUser() != null;
- }
-
- private void attachRecyclerViewAdapter() {
- final RecyclerView.Adapter adapter = newAdapter();
-
- // Scroll to bottom on new messages
- adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
- @Override
- public void onItemRangeInserted(int positionStart, int itemCount) {
- mBinding.messagesList.smoothScrollToPosition(adapter.getItemCount());
- }
- });
-
- mBinding.messagesList.setAdapter(adapter);
- }
-
- public void onSendClick() {
- String uid = FirebaseAuth.getInstance().getCurrentUser().getUid();
- String name = "User " + uid.substring(0, 6);
-
- onAddMessage(new Chat(name, mBinding.messageEdit.getText().toString(), uid));
-
- mBinding.messageEdit.setText("");
- }
-
- @NonNull
- protected RecyclerView.Adapter newAdapter() {
- FirebaseRecyclerOptions options =
- new FirebaseRecyclerOptions.Builder()
- .setQuery(sChatQuery, Chat.class)
- .setLifecycleOwner(this)
- .build();
-
- return new FirebaseRecyclerAdapter(options) {
- @Override
- public ChatHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- return new ChatHolder(LayoutInflater.from(parent.getContext())
- .inflate(R.layout.message, parent, false));
- }
-
- @Override
- protected void onBindViewHolder(@NonNull ChatHolder holder, int position, @NonNull Chat model) {
- holder.bind(model);
- }
-
- @Override
- public void onDataChanged() {
- // If there are no chat messages, show a view that invites the user to add a message.
- mBinding.emptyTextView.setVisibility(getItemCount() == 0 ? View.VISIBLE : View.GONE);
- }
- };
- }
-
- protected void onAddMessage(@NonNull Chat chat) {
- sChatQuery.getRef().push().setValue(chat, (error, reference) -> {
- if (error != null) {
- Log.e(TAG, "Failed to write message", error.toException());
- }
- });
- }
-}
diff --git a/app/src/main/java/com/firebase/uidemo/database/realtime/RealtimeDbChatIndexActivity.java b/app/src/main/java/com/firebase/uidemo/database/realtime/RealtimeDbChatIndexActivity.java
deleted file mode 100644
index c55b2cd24..000000000
--- a/app/src/main/java/com/firebase/uidemo/database/realtime/RealtimeDbChatIndexActivity.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.firebase.uidemo.database.realtime;
-
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import com.firebase.ui.database.FirebaseRecyclerAdapter;
-import com.firebase.ui.database.FirebaseRecyclerOptions;
-import com.firebase.uidemo.R;
-import com.firebase.uidemo.database.ChatHolder;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.database.DatabaseReference;
-import com.google.firebase.database.FirebaseDatabase;
-
-import androidx.annotation.NonNull;
-
-public class RealtimeDbChatIndexActivity extends RealtimeDbChatActivity {
- private DatabaseReference mChatIndicesRef;
-
- @NonNull
- @Override
- protected FirebaseRecyclerAdapter newAdapter() {
- mChatIndicesRef = FirebaseDatabase.getInstance()
- .getReference()
- .child("chatIndices")
- .child(FirebaseAuth.getInstance().getCurrentUser().getUid());
-
- FirebaseRecyclerOptions options =
- new FirebaseRecyclerOptions.Builder()
- .setIndexedQuery(
- mChatIndicesRef.limitToFirst(50), sChatQuery.getRef(), Chat.class)
- .setLifecycleOwner(this)
- .build();
-
- return new FirebaseRecyclerAdapter(options) {
- @NonNull
- @Override
- public ChatHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- return new ChatHolder(LayoutInflater.from(parent.getContext())
- .inflate(R.layout.message, parent, false));
- }
-
- @Override
- protected void onBindViewHolder(@NonNull ChatHolder holder, int position, @NonNull Chat model) {
- holder.bind(model);
- }
-
- @Override
- public void onDataChanged() {
- // If there are no chat messages, show a view that invites the user to add a message.
- findViewById(R.id.emptyTextView).setVisibility(getItemCount() == 0 ? View.VISIBLE : View.GONE);
- }
- };
- }
-
- @Override
- protected void onAddMessage(@NonNull Chat chat) {
- DatabaseReference chatRef = sChatQuery.getRef().push();
- mChatIndicesRef.child(chatRef.getKey()).setValue(true);
- chatRef.setValue(chat);
- }
-}
diff --git a/app/src/main/java/com/firebase/uidemo/storage/ImageActivity.java b/app/src/main/java/com/firebase/uidemo/storage/ImageActivity.java
deleted file mode 100644
index 67232fdfe..000000000
--- a/app/src/main/java/com/firebase/uidemo/storage/ImageActivity.java
+++ /dev/null
@@ -1,165 +0,0 @@
-package com.firebase.uidemo.storage;
-
-import android.Manifest;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.provider.MediaStore;
-import android.util.Log;
-import android.view.View;
-import android.widget.Toast;
-
-import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
-import com.firebase.uidemo.BuildConfig;
-import com.firebase.uidemo.R;
-import com.firebase.uidemo.databinding.ActivityImageBinding;
-import com.firebase.uidemo.util.SignInResultNotifier;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.storage.FirebaseStorage;
-import com.google.firebase.storage.StorageReference;
-import com.google.firebase.storage.UploadTask;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.UUID;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.view.WindowCompat;
-import pub.devrel.easypermissions.AfterPermissionGranted;
-import pub.devrel.easypermissions.AppSettingsDialog;
-import pub.devrel.easypermissions.EasyPermissions;
-
-public class ImageActivity extends AppCompatActivity implements EasyPermissions.PermissionCallbacks {
-
- private static final String TAG = "ImageDemo";
- private static final int RC_CHOOSE_PHOTO = 101;
- private static final int RC_IMAGE_PERMS = 102;
- private static final String PERMS = Manifest.permission.READ_EXTERNAL_STORAGE;
-
- private StorageReference mImageRef;
-
- private ActivityImageBinding mBinding;
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- // Enable edge-to-edge
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
-
- mBinding = ActivityImageBinding.inflate(getLayoutInflater());
- setContentView(mBinding.getRoot());
-
- mBinding.buttonDownloadDirect.setOnClickListener(view -> {
- // Download directly from StorageReference using Glide
- // (See MyAppGlideModule for Loader registration)
- GlideApp.with(ImageActivity.this)
- .load(mImageRef)
- .centerCrop()
- .transition(DrawableTransitionOptions.withCrossFade())
- .into(mBinding.firstImage);
- });
-
- mBinding.buttonChoosePhoto.setOnClickListener(view -> choosePhoto());
-
- // By default, Cloud Storage files require authentication to read or write.
- // For this sample to function correctly, enable Anonymous Auth in the Firebase console:
- // https://console.firebase.google.com/project/_/authentication/providers
- FirebaseAuth.getInstance()
- .signInAnonymously()
- .addOnCompleteListener(new SignInResultNotifier(this));
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
-
- if (requestCode == RC_CHOOSE_PHOTO) {
- if (resultCode == RESULT_OK) {
- Uri selectedImage = data.getData();
- uploadPhoto(selectedImage);
- } else {
- Toast.makeText(this, "No image chosen", Toast.LENGTH_SHORT).show();
- }
- } else if (requestCode == AppSettingsDialog.DEFAULT_SETTINGS_REQ_CODE
- && EasyPermissions.hasPermissions(this, PERMS)) {
- choosePhoto();
- }
- }
-
- @AfterPermissionGranted(RC_IMAGE_PERMS)
- protected void choosePhoto() {
- if (!EasyPermissions.hasPermissions(this, PERMS)) {
- EasyPermissions.requestPermissions(this, getString(R.string.rational_image_perm),
- RC_IMAGE_PERMS, PERMS);
- return;
- }
-
- Intent i = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
- startActivityForResult(i, RC_CHOOSE_PHOTO);
- }
-
- private void uploadPhoto(Uri uri) {
- // Reset UI
- hideDownloadUI();
- Toast.makeText(this, "Uploading...", Toast.LENGTH_SHORT).show();
-
- // Upload to Cloud Storage
- String uuid = UUID.randomUUID().toString();
- mImageRef = FirebaseStorage.getInstance().getReference(uuid);
- mImageRef.putFile(uri)
- .addOnSuccessListener(this, taskSnapshot -> {
- if (BuildConfig.DEBUG) {
- Log.d(TAG, "uploadPhoto:onSuccess:" +
- taskSnapshot.getMetadata().getReference().getPath());
- }
- Toast.makeText(ImageActivity.this, "Image uploaded",
- Toast.LENGTH_SHORT).show();
-
- showDownloadUI();
- })
- .addOnFailureListener(this, e -> {
- Log.w(TAG, "uploadPhoto:onError", e);
- Toast.makeText(ImageActivity.this, "Upload failed",
- Toast.LENGTH_SHORT).show();
- });
- }
-
- private void hideDownloadUI() {
- mBinding.buttonDownloadDirect.setEnabled(false);
-
- mBinding.firstImage.setImageResource(0);
- mBinding.firstImage.setVisibility(View.INVISIBLE);
- }
-
- private void showDownloadUI() {
- mBinding.buttonDownloadDirect.setEnabled(true);
-
- mBinding.firstImage.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode,
- @NonNull String[] permissions,
- @NonNull int[] grantResults) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
- }
-
- @Override
- public void onPermissionsGranted(int requestCode, @NonNull List perms) {
- // See #choosePhoto with @AfterPermissionGranted
- }
-
- @Override
- public void onPermissionsDenied(int requestCode, @NonNull List perms) {
- if (EasyPermissions.somePermissionPermanentlyDenied(this,
- Collections.singletonList(PERMS))) {
- new AppSettingsDialog.Builder(this).build().show();
- }
- }
-}
diff --git a/app/src/main/java/com/firebase/uidemo/storage/MyAppGlideModule.java b/app/src/main/java/com/firebase/uidemo/storage/MyAppGlideModule.java
deleted file mode 100644
index bc1f11568..000000000
--- a/app/src/main/java/com/firebase/uidemo/storage/MyAppGlideModule.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.firebase.uidemo.storage;
-
-import android.content.Context;
-
-import com.bumptech.glide.Glide;
-import com.bumptech.glide.Registry;
-import com.bumptech.glide.annotation.GlideModule;
-import com.bumptech.glide.module.AppGlideModule;
-import com.firebase.ui.storage.images.FirebaseImageLoader;
-import com.google.firebase.storage.StorageReference;
-
-import java.io.InputStream;
-
-import androidx.annotation.NonNull;
-
-/**
- * Glide module to register {@link com.firebase.ui.storage.images.FirebaseImageLoader}.
- * See: http://bumptech.github.io/glide/doc/generatedapi.html
- */
-@GlideModule
-public class MyAppGlideModule extends AppGlideModule {
-
- @Override
- public void registerComponents(@NonNull Context context,
- @NonNull Glide glide,
- @NonNull Registry registry) {
- // Register FirebaseImageLoader to handle StorageReference
- registry.append(StorageReference.class, InputStream.class,
- new FirebaseImageLoader.Factory());
- }
-
-}
diff --git a/app/src/main/java/com/firebase/uidemo/util/ConfigurationUtils.java b/app/src/main/java/com/firebase/uidemo/util/ConfigurationUtils.java
deleted file mode 100644
index bb7545bdf..000000000
--- a/app/src/main/java/com/firebase/uidemo/util/ConfigurationUtils.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.firebase.uidemo.util;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.uidemo.R;
-import com.google.firebase.auth.ActionCodeSettings;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import androidx.annotation.NonNull;
-
-@SuppressLint("RestrictedApi")
-public final class ConfigurationUtils {
-
- private ConfigurationUtils() {
- throw new AssertionError("No instance for you!");
- }
-
- public static boolean isGoogleMisconfigured(@NonNull Context context) {
- return AuthUI.UNCONFIGURED_CONFIG_VALUE.equals(
- context.getString(R.string.default_web_client_id));
- }
-
- public static boolean isFacebookMisconfigured(@NonNull Context context) {
- return AuthUI.UNCONFIGURED_CONFIG_VALUE.equals(
- context.getString(R.string.facebook_application_id));
- }
-
- @NonNull
- public static List getConfiguredProviders(@NonNull Context context) {
- List providers = new ArrayList<>();
-
- if (!isGoogleMisconfigured(context)) {
- providers.add(new AuthUI.IdpConfig.GoogleBuilder().build());
- }
-
- if (!isFacebookMisconfigured(context)) {
- providers.add(new AuthUI.IdpConfig.FacebookBuilder().build());
- }
-
- ActionCodeSettings actionCodeSettings = ActionCodeSettings.newBuilder()
- .setAndroidPackageName("com.firebase.uidemo", true, null)
- .setHandleCodeInApp(true)
- .setUrl("https://google.com")
- .build();
-
- providers.add(new AuthUI.IdpConfig.EmailBuilder()
- .setAllowNewAccounts(true)
- .enableEmailLinkSignIn()
- .setActionCodeSettings(actionCodeSettings)
- .build());
-
- providers.add(new AuthUI.IdpConfig.TwitterBuilder().build());
- providers.add(new AuthUI.IdpConfig.PhoneBuilder().build());
- providers.add(new AuthUI.IdpConfig.MicrosoftBuilder().build());
- providers.add(new AuthUI.IdpConfig.YahooBuilder().build());
- providers.add(new AuthUI.IdpConfig.AppleBuilder().build());
-
- return providers;
- }
-}
diff --git a/app/src/main/java/com/firebase/uidemo/util/SignInResultNotifier.java b/app/src/main/java/com/firebase/uidemo/util/SignInResultNotifier.java
deleted file mode 100644
index ec3d96f0e..000000000
--- a/app/src/main/java/com/firebase/uidemo/util/SignInResultNotifier.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.firebase.uidemo.util;
-
-import android.content.Context;
-import android.widget.Toast;
-
-import com.firebase.uidemo.R;
-import com.google.android.gms.tasks.OnCompleteListener;
-import com.google.android.gms.tasks.Task;
-import com.google.firebase.auth.AuthResult;
-
-import androidx.annotation.NonNull;
-
-/**
- * Notifies the user of sign in successes or failures beyond the lifecycle of an activity.
- */
-public class SignInResultNotifier implements OnCompleteListener {
- private Context mContext;
-
- public SignInResultNotifier(@NonNull Context context) {
- mContext = context.getApplicationContext();
- }
-
- @Override
- public void onComplete(@NonNull Task task) {
- if (task.isSuccessful()) {
- Toast.makeText(mContext, R.string.signed_in, Toast.LENGTH_SHORT).show();
- } else {
- Toast.makeText(mContext, R.string.anonymous_auth_failed_msg, Toast.LENGTH_LONG).show();
- }
- }
-}
diff --git a/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt
new file mode 100644
index 000000000..ed6de15a6
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/AuthFlowControllerDemoActivity.kt
@@ -0,0 +1,335 @@
+package com.firebaseui.android.demo
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.AuthFlowController
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthActivity
+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.auth_provider.AuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.launch
+
+/**
+ * Demo activity showcasing the AuthFlowController API for managing
+ * Firebase authentication with lifecycle-safe control.
+ *
+ * This demonstrates:
+ * - Creating an AuthFlowController with configuration
+ * - Starting the auth flow using ActivityResultLauncher
+ * - Observing auth state changes
+ * - Handling results (success, cancelled, error)
+ * - Proper lifecycle management with dispose()
+ */
+class AuthFlowControllerDemoActivity : ComponentActivity() {
+
+ private lateinit var authController: AuthFlowController
+
+ // Modern ActivityResultLauncher for auth flow
+ private val authLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ // Get user data from result
+ val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)
+ val isNewUser = result.data?.getBooleanExtra(
+ FirebaseAuthActivity.EXTRA_IS_NEW_USER,
+ false
+ ) ?: false
+
+ val user = FirebaseAuth.getInstance().currentUser
+ val message = if (isNewUser) {
+ "Welcome new user! ${user?.email ?: userId}"
+ } else {
+ "Welcome back! ${user?.email ?: userId}"
+ }
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ }
+ Activity.RESULT_CANCELED -> {
+ Toast.makeText(this, "Auth cancelled", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize FirebaseAuthUI
+ val authUI = FirebaseAuthUI.getInstance()
+
+ // Create auth configuration
+ val configuration = AuthUIConfiguration(
+ context = applicationContext,
+ providers = listOf(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkForceSameDeviceEnabled = true,
+ isEmailLinkSignInEnabled = false,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://flutterfire-e2e-tests.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "com.firebaseui.android.demo",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ )
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 120L,
+ isInstantVerificationEnabled = true
+ ),
+ AuthProvider.Facebook()
+ ),
+ tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1",
+ privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1"
+ )
+
+ // Create AuthFlowController
+ authController = authUI.createAuthFlow(configuration)
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthFlowDemo(
+ authController = authController,
+ onStartAuth = { startAuthFlow() },
+ onCancelAuth = { cancelAuthFlow() }
+ )
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Clean up resources
+ authController.dispose()
+ }
+
+ private fun startAuthFlow() {
+ val intent = authController.createIntent(this)
+ authLauncher.launch(intent)
+ }
+
+ private fun cancelAuthFlow() {
+ authController.cancel()
+ Toast.makeText(this, "Auth flow cancelled", Toast.LENGTH_SHORT).show()
+ }
+
+ companion object {
+ fun createIntent(context: Context): Intent {
+ return Intent(context, AuthFlowControllerDemoActivity::class.java)
+ }
+ }
+}
+
+@Composable
+fun AuthFlowDemo(
+ authController: AuthFlowController,
+ onStartAuth: () -> Unit,
+ onCancelAuth: () -> Unit
+) {
+ val authState by authController.authStateFlow.collectAsState(AuthState.Idle)
+ var currentUser by remember { mutableStateOf(FirebaseAuth.getInstance().currentUser) }
+
+ // Observe Firebase auth state changes
+ DisposableEffect(Unit) {
+ val authStateListener = FirebaseAuth.AuthStateListener { auth ->
+ currentUser = auth.currentUser
+ }
+ FirebaseAuth.getInstance().addAuthStateListener(authStateListener)
+
+ onDispose {
+ FirebaseAuth.getInstance().removeAuthStateListener(authStateListener)
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
+ ) {
+ Text(
+ text = "⚙️ Low-Level API Demo",
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "AuthFlowController with ActivityResultLauncher",
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = "This demonstrates manual control over the authentication flow with lifecycle-safe management.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Current Auth State Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Current State:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = when (authState) {
+ is AuthState.Idle -> "Idle"
+ is AuthState.Loading -> "Loading: ${(authState as AuthState.Loading).message}"
+ is AuthState.Success -> "Success - User: ${(authState as AuthState.Success).user.email}"
+ is AuthState.Error -> "Error: ${(authState as AuthState.Error).exception.message}"
+ is AuthState.Cancelled -> "Cancelled"
+ is AuthState.RequiresMfa -> "MFA Required"
+ is AuthState.RequiresEmailVerification -> "Email Verification Required"
+ else -> "Unknown"
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = when (authState) {
+ is AuthState.Success -> MaterialTheme.colorScheme.primary
+ is AuthState.Error -> MaterialTheme.colorScheme.error
+ is AuthState.Loading -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+
+ // Current User Card
+ currentUser?.let { user ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Signed In User:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "Email: ${user.email ?: "N/A"}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = "UID: ${user.uid}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Action Buttons
+ if (currentUser == null) {
+ Button(
+ onClick = onStartAuth,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Start Auth Flow")
+ }
+
+ if (authState is AuthState.Loading) {
+ OutlinedButton(
+ onClick = onCancelAuth,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Cancel Auth Flow")
+ }
+ }
+ } else {
+ Button(
+ onClick = {
+ FirebaseAuth.getInstance().signOut()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Sign Out")
+ }
+ }
+
+ // Info Card
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Lifecycle-safe auth flow management",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Observable auth state with Flow",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Modern ActivityResultLauncher API",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = "• Automatic resource cleanup",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
new file mode 100644
index 000000000..79ed7068a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/CustomSlotsThemingDemoActivity.kt
@@ -0,0 +1,825 @@
+package com.firebaseui.android.demo
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.AuthException
+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.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebase.ui.auth.ui.screens.email.EmailAuthMode
+import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep
+import com.google.firebase.auth.AuthResult
+
+/**
+ * Demo activity showcasing custom slots and theming capabilities:
+ * - EmailAuthScreen with custom slot UI
+ * - PhoneAuthScreen with custom slot UI
+ * - AuthUITheme.fromMaterialTheme() with custom ProviderStyle overrides
+ */
+class CustomSlotsThemingDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val appContext = applicationContext
+
+ // Configuration for email authentication
+ val emailConfiguration = authUIConfiguration {
+ context = appContext
+ providers {
+ provider(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isNewAccountsAllowed = true,
+ isEmailLinkSignInEnabled = false,
+ emailLinkActionCodeSettings = null,
+ isEmailLinkForceSameDeviceEnabled = false,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireDigit
+ )
+ )
+ )
+ }
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ }
+
+ // Configuration for phone authentication
+ val phoneConfiguration = authUIConfiguration {
+ context = appContext
+ providers {
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = "US",
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 60L,
+ isInstantVerificationEnabled = true
+ )
+ )
+ }
+ }
+
+ setContent {
+ // Custom theme using fromMaterialTheme() with custom provider styles
+ CustomAuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ var selectedDemo by remember { mutableStateOf(DemoType.Email) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ // Demo selector tabs
+ DemoSelector(
+ selectedDemo = selectedDemo,
+ onDemoSelected = { selectedDemo = it }
+ )
+
+ // Show selected demo
+ when (selectedDemo) {
+ DemoType.Email -> EmailAuthDemo(
+ authUI = authUI,
+ configuration = emailConfiguration,
+ context = appContext
+ )
+ DemoType.Phone -> PhoneAuthDemo(
+ authUI = authUI,
+ configuration = phoneConfiguration,
+ context = appContext
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+enum class DemoType {
+ Email,
+ Phone
+}
+
+@Composable
+fun CustomAuthUITheme(content: @Composable () -> Unit) {
+ // Use Material Theme colors
+ MaterialTheme {
+ val customProviderStyles = mapOf(
+ "google.com" to AuthUITheme.ProviderStyle(
+ icon = null, // Would use actual Google icon in production
+ backgroundColor = Color(0xFFFFFFFF),
+ contentColor = Color(0xFF757575),
+ iconTint = null,
+ shape = RoundedCornerShape(8.dp),
+ elevation = 1.dp
+ ),
+ "facebook.com" to AuthUITheme.ProviderStyle(
+ icon = null, // Would use actual Facebook icon in production
+ backgroundColor = Color(0xFF1877F2),
+ contentColor = Color.White,
+ iconTint = null,
+ shape = RoundedCornerShape(8.dp),
+ elevation = 2.dp
+ ),
+ "password" to AuthUITheme.ProviderStyle(
+ icon = null,
+ backgroundColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ iconTint = null,
+ shape = RoundedCornerShape(12.dp),
+ elevation = 3.dp
+ )
+ )
+
+ // Apply custom theme using fromMaterialTheme
+ val authTheme = AuthUITheme.fromMaterialTheme(providerStyles = customProviderStyles)
+
+ AuthUITheme(theme = authTheme) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun DemoSelector(
+ selectedDemo: DemoType,
+ onDemoSelected: (DemoType) -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Custom Slots & Theming Demo",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ Text(
+ text = "Select a demo to see custom UI implementations using slot APIs",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ FilterChip(
+ selected = selectedDemo == DemoType.Email,
+ onClick = { onDemoSelected(DemoType.Email) },
+ label = { Text("Email Auth") },
+ modifier = Modifier.weight(1f)
+ )
+ FilterChip(
+ selected = selectedDemo == DemoType.Phone,
+ onClick = { onDemoSelected(DemoType.Phone) },
+ label = { Text("Phone Auth") },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun EmailAuthDemo(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ context: android.content.Context
+) {
+ var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
+
+ // Monitor auth state changes
+ LaunchedEffect(Unit) {
+ authUI.authStateFlow().collect { _ ->
+ currentUser = authUI.getCurrentUser()
+ }
+ }
+
+ if (currentUser != null) {
+ // Show success screen
+ val successScrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(successScrollState)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "✓",
+ style = MaterialTheme.typography.displayLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Successfully Authenticated!",
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = currentUser?.email ?: "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = {
+ authUI.auth.signOut()
+ }) {
+ Text("Sign Out")
+ }
+ }
+ } else {
+ // Show custom email auth UI using slot API
+ // Provide the string provider required by EmailAuthScreen
+ CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result: AuthResult ->
+ Log.d("CustomSlotsDemo", "Email auth success: ${result.user?.uid}")
+ },
+ onError = { exception: AuthException ->
+ Log.e("CustomSlotsDemo", "Email auth error", exception)
+ },
+ onCancel = {
+ Log.d("CustomSlotsDemo", "Email auth cancelled")
+ }
+ ) { state: EmailAuthContentState ->
+ // Custom UI using the slot API
+ CustomEmailAuthUI(state)
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomEmailAuthUI(state: EmailAuthContentState) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Title based on mode
+ Text(
+ text = when (state.mode) {
+ EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "📧 Welcome Back"
+ EmailAuthMode.SignUp -> "📧 Create Account"
+ EmailAuthMode.ResetPassword -> "📧 Reset Password"
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Error display
+ state.error?.let { errorMessage ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = errorMessage,
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ // Render UI based on mode
+ when (state.mode) {
+ EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state)
+ EmailAuthMode.SignUp -> SignUpUI(state)
+ EmailAuthMode.ResetPassword -> ResetPasswordUI(state)
+ }
+ }
+}
+
+@Composable
+fun SignInUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ if (state.emailSignInLinkSent) {
+ Text(
+ text = "✓ Sign-in link sent! Check your email.",
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSignInClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Sign In")
+ }
+ }
+
+ TextButton(
+ onClick = state.onGoToResetPassword,
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Text("Forgot Password?")
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignUp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Don't have an account? Sign Up")
+ }
+ }
+}
+
+@Composable
+fun SignUpUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = state.displayName,
+ onValueChange = state.onDisplayNameChange,
+ label = { Text("Display Name") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ OutlinedTextField(
+ value = state.confirmPassword,
+ onValueChange = state.onConfirmPasswordChange,
+ label = { Text("Confirm Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSignUpClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Create Account")
+ }
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Already have an account? Sign In")
+ }
+ }
+}
+
+@Composable
+fun ResetPasswordUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Enter your email address and we'll send you a link to reset your password.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ if (state.resetLinkSent) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "✓ Password reset link sent! Check your email.",
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onSendResetLinkClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && !state.resetLinkSent
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Send Reset Link")
+ }
+ }
+
+ HorizontalDivider()
+
+ TextButton(
+ onClick = state.onGoToSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Back to Sign In")
+ }
+ }
+}
+
+@Composable
+fun PhoneAuthDemo(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ context: android.content.Context
+) {
+ var currentUser by remember { mutableStateOf(authUI.getCurrentUser()) }
+
+ // Monitor auth state changes
+ LaunchedEffect(Unit) {
+ authUI.authStateFlow().collect { _ ->
+ currentUser = authUI.getCurrentUser()
+ }
+ }
+
+ if (currentUser != null) {
+ // Show success screen
+ val successScrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(successScrollState)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "📱",
+ style = MaterialTheme.typography.displayLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Phone Verified!",
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = currentUser?.phoneNumber ?: "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(onClick = {
+ authUI.auth.signOut()
+ }) {
+ Text("Sign Out")
+ }
+ }
+ } else {
+ // Show custom phone auth UI using slot API
+ // Provide the string provider required by PhoneAuthScreen
+ CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
+ PhoneAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result: AuthResult ->
+ Log.d("CustomSlotsDemo", "Phone auth success: ${result.user?.uid}")
+ },
+ onError = { exception: AuthException ->
+ Log.e("CustomSlotsDemo", "Phone auth error", exception)
+ },
+ onCancel = {
+ Log.d("CustomSlotsDemo", "Phone auth cancelled")
+ }
+ ) { state: PhoneAuthContentState ->
+ // Custom UI using the slot API
+ CustomPhoneAuthUI(state)
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomPhoneAuthUI(state: PhoneAuthContentState) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Title based on step
+ Text(
+ text = when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> "📱 Phone Verification"
+ PhoneAuthStep.EnterVerificationCode -> "📱 Enter Code"
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Error display
+ state.error?.let { errorMessage ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = errorMessage,
+ modifier = Modifier.padding(12.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ // Render UI based on step
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> EnterPhoneNumberUI(state)
+ PhoneAuthStep.EnterVerificationCode -> EnterVerificationCodeUI(state)
+ }
+ }
+}
+
+@Composable
+fun EnterPhoneNumberUI(state: PhoneAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "Enter your phone number to receive a verification code",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Country selector (simplified for demo)
+ OutlinedCard(
+ onClick = { /* In real app, open country selector */ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${state.selectedCountry.flagEmoji} ${state.selectedCountry.dialCode}",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = state.selectedCountry.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ OutlinedTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ label = { Text("Phone Number") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = state.onSendCodeClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && state.phoneNumber.isNotBlank()
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Send Code")
+ }
+ }
+ }
+}
+
+@Composable
+fun EnterVerificationCodeUI(state: PhoneAuthContentState) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "We sent a verification code to:",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = state.fullPhoneNumber,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = state.verificationCode,
+ onValueChange = state.onVerificationCodeChange,
+ label = { Text("6-Digit Code") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = !state.isLoading
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Button(
+ onClick = state.onVerifyCodeClick,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !state.isLoading && state.verificationCode.length == 6
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Text("Verify Code")
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ TextButton(onClick = state.onChangeNumberClick) {
+ Text("Change Number")
+ }
+
+ TextButton(
+ onClick = state.onResendCodeClick,
+ enabled = state.resendTimer == 0
+ ) {
+ Text(
+ if (state.resendTimer > 0)
+ "Resend (${state.resendTimer}s)"
+ else
+ "Resend Code"
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
new file mode 100644
index 000000000..570f6135f
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/HighLevelApiDemoActivity.kt
@@ -0,0 +1,285 @@
+package com.firebaseui.android.demo
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.util.EmailLinkConstants
+import com.google.firebase.auth.actionCodeSettings
+
+class HighLevelApiDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ logo = AuthUIAsset.Resource(R.drawable.firebase_auth)
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ isAnonymousUpgradeEnabled = false
+ providers {
+ provider(AuthProvider.Anonymous)
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
+ )
+ )
+ provider(
+ AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkForceSameDeviceEnabled = false,
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://flutterfire-e2e-tests.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "com.firebaseui.android.demo",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ ),
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = emptyList(),
+ smsCodeLength = 6,
+ timeout = 120L,
+ isInstantVerificationEnabled = true
+ )
+ )
+ provider(
+ AuthProvider.Facebook()
+ )
+ provider(
+ AuthProvider.Twitter(
+ customParameters = emptyMap()
+ )
+ )
+ provider(
+ AuthProvider.Apple(
+ customParameters = emptyMap(),
+ locale = null
+ )
+ )
+ provider(
+ AuthProvider.Microsoft(
+ scopes = emptyList(),
+ tenant = "",
+ customParameters = emptyMap(),
+ )
+ )
+ provider(
+ AuthProvider.Github(
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ )
+ )
+ provider(
+ AuthProvider.Yahoo(
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ )
+ )
+ provider(
+ AuthProvider.GenericOAuth(
+ providerName = "LINE",
+ providerId = "oidc.line",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Sign in with LINE",
+ buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line_logo_24dp),
+ buttonColor = Color(0xFF06C755),
+ contentColor = Color.White
+ )
+ )
+ provider(
+ AuthProvider.GenericOAuth(
+ providerName = "Discord",
+ providerId = "oidc.discord",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Sign in with Discord",
+ buttonIcon = AuthUIAsset.Resource(R.drawable.ic_discord_24dp),
+ buttonColor = Color(0xFF5865F2),
+ contentColor = Color.White
+ )
+ )
+ }
+ }
+
+ setContent {
+ AuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ emailLink = emailLink,
+ onSignInSuccess = { result ->
+ Log.d("HighLevelApiDemoActivity", "Authentication success: ${result.user?.uid}")
+ },
+ onSignInFailure = { exception: AuthException ->
+ Log.e("HighLevelApiDemoActivity", "Authentication failed", exception)
+ },
+ onSignInCancelled = {
+ Log.d("HighLevelApiDemoActivity", "Authentication cancelled")
+ },
+ authenticatedContent = { state, uiContext ->
+ AppAuthenticatedContent(state, uiContext)
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AppAuthenticatedContent(
+ state: AuthState,
+ uiContext: AuthSuccessUiContext
+) {
+ val stringProvider = uiContext.stringProvider
+ when (state) {
+ is AuthState.Success -> {
+ val user = uiContext.authUI.getCurrentUser()
+ val identifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty()
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (identifier.isNotBlank()) {
+ Text(
+ text = stringProvider.signedInAs(identifier),
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ Text(
+ "isAnonymous - ${state.user.isAnonymous}",
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ "Providers - ${state.user.providerData.map { it.providerId }}",
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = uiContext.onManageMfa) {
+ Text(stringProvider.manageMfaAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+ }
+
+ is AuthState.RequiresEmailVerification -> {
+ val email = uiContext.authUI.getCurrentUser()?.email ?: stringProvider.emailProvider
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringProvider.verifyEmailInstruction(email),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = { uiContext.authUI.getCurrentUser()?.sendEmailVerification() }) {
+ Text(stringProvider.resendVerificationEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onReloadUser) {
+ Text(stringProvider.verifiedEmailAction)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+ }
+
+ is AuthState.RequiresProfileCompletion -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = stringProvider.profileCompletionMessage,
+ textAlign = TextAlign.Center
+ )
+ if (state.missingFields.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = stringProvider.profileMissingFieldsMessage(state.missingFields.joinToString()),
+ textAlign = TextAlign.Center
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = uiContext.onSignOut) {
+ Text(stringProvider.signOutAction)
+ }
+ }
+ }
+
+ else -> {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
new file mode 100644
index 000000000..a22e6028a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
@@ -0,0 +1,273 @@
+package com.firebaseui.android.demo
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.util.EmailLinkConstants
+import com.google.firebase.FirebaseApp
+
+/**
+ * Main launcher activity that allows users to choose between different
+ * authentication API demonstrations.
+ */
+class MainActivity : ComponentActivity() {
+ companion object {
+ private const val USE_AUTH_EMULATOR = false
+ private const val AUTH_EMULATOR_HOST = "10.0.2.2"
+ private const val AUTH_EMULATOR_PORT = 9099
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Initialize Firebase and configure emulator if needed
+ FirebaseApp.initializeApp(applicationContext)
+ val authUI = FirebaseAuthUI.getInstance()
+
+ if (USE_AUTH_EMULATOR) {
+ authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT)
+ }
+
+ var pendingEmailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
+
+ if (pendingEmailLink.isNullOrEmpty() && authUI.canHandleIntent(intent)) {
+ pendingEmailLink = intent.data?.toString()
+ }
+
+ Log.d("MainActivity", "Pending email link: $pendingEmailLink")
+
+ fun launchHighLevelDemo() {
+ val demoIntent = Intent(
+ this,
+ HighLevelApiDemoActivity::class.java
+ ).apply {
+ pendingEmailLink?.let { link ->
+ putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, link)
+ pendingEmailLink = null
+ }
+ }
+ startActivity(demoIntent)
+ }
+
+ if (savedInstanceState == null && !pendingEmailLink.isNullOrEmpty()) {
+ launchHighLevelDemo()
+ finish()
+ return
+ }
+
+ setContent {
+ MaterialTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ ChooserScreen(
+ onHighLevelApiClick = ::launchHighLevelDemo,
+ onLowLevelApiClick = {
+ startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java))
+ },
+ onCustomSlotsClick = {
+ startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java))
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ChooserScreen(
+ onHighLevelApiClick: () -> Unit,
+ onLowLevelApiClick: () -> Unit,
+ onCustomSlotsClick: () -> Unit
+) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .systemBarsPadding()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+ // Header
+ Text(
+ text = "Firebase Auth UI Compose",
+ style = MaterialTheme.typography.headlineLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Text(
+ text = "Choose a demo to explore different authentication APIs",
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // High-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onHighLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "🎨 High-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "FirebaseAuthScreen Composable",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Pure Compose applications that want a complete, ready-to-use authentication UI with minimal setup.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Drop-in Composable\n• Automatic navigation\n• State management included\n• Customizable content",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Low-Level API Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onLowLevelApiClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "⚙️ Low-Level API",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "AuthFlowController",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Applications that need fine-grained control over the authentication flow with ActivityResultLauncher integration.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Lifecycle-safe controller\n• ActivityResultLauncher\n• Observable state with Flow\n• Manual flow control",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Custom Slots & Theming Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onCustomSlotsClick
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "🎨 Custom Slots & Theming",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Slot APIs & Theme Customization",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Best for: Applications that need fully custom UI while leveraging the authentication logic and state management.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Features:",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "• Custom email auth UI via slots\n• Custom phone auth UI via slots\n• AuthUITheme.fromMaterialTheme()\n• Custom ProviderStyle examples",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Info card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "💡 Tip",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Text(
+ text = "Both APIs provide the same authentication capabilities. Choose based on your app's architecture and control requirements.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/ui/theme/Color.kt b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Color.kt
new file mode 100644
index 000000000..2bf0276ff
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.firebaseui.android.demo.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app/src/main/java/com/firebaseui/android/demo/ui/theme/Theme.kt b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Theme.kt
new file mode 100644
index 000000000..401faae8c
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.firebaseui.android.demo.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FirebaseUIAndroidTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/firebaseui/android/demo/ui/theme/Type.kt b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Type.kt
new file mode 100644
index 000000000..0bfbc900e
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.firebaseui.android.demo.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/app/src/main/res/drawable-hdpi/firebase_auth.png b/app/src/main/res/drawable-hdpi/firebase_auth.png
new file mode 100644
index 000000000..fecbcb6dd
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/firebase_auth.png differ
diff --git a/app/src/main/res/drawable-hdpi/firebase_auth_120dp.png b/app/src/main/res/drawable-hdpi/firebase_auth_120dp.png
deleted file mode 100644
index b03b18b53..000000000
Binary files a/app/src/main/res/drawable-hdpi/firebase_auth_120dp.png and /dev/null differ
diff --git a/app/src/main/res/drawable-mdpi/firebase_auth.png b/app/src/main/res/drawable-mdpi/firebase_auth.png
new file mode 100644
index 000000000..bc9af3cc0
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/firebase_auth.png differ
diff --git a/app/src/main/res/drawable-mdpi/firebase_auth_120dp.png b/app/src/main/res/drawable-mdpi/firebase_auth_120dp.png
deleted file mode 100644
index 820ba76f9..000000000
Binary files a/app/src/main/res/drawable-mdpi/firebase_auth_120dp.png and /dev/null differ
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..fde1368fc
--- /dev/null
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable-xhdpi/firebase_auth.png b/app/src/main/res/drawable-xhdpi/firebase_auth.png
new file mode 100644
index 000000000..8a93e39a6
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/firebase_auth.png differ
diff --git a/app/src/main/res/drawable-xhdpi/firebase_auth_120dp.png b/app/src/main/res/drawable-xhdpi/firebase_auth_120dp.png
deleted file mode 100644
index 351fa59fe..000000000
Binary files a/app/src/main/res/drawable-xhdpi/firebase_auth_120dp.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xxhdpi/firebase_auth.png b/app/src/main/res/drawable-xxhdpi/firebase_auth.png
new file mode 100644
index 000000000..c01b18b14
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/firebase_auth.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/firebase_auth_120dp.png b/app/src/main/res/drawable-xxhdpi/firebase_auth_120dp.png
deleted file mode 100644
index 6a6374e30..000000000
Binary files a/app/src/main/res/drawable-xxhdpi/firebase_auth_120dp.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xxxhdpi/firebase_auth.png b/app/src/main/res/drawable-xxxhdpi/firebase_auth.png
new file mode 100644
index 000000000..221da4d3a
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/firebase_auth.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/firebase_auth_120dp.png b/app/src/main/res/drawable-xxxhdpi/firebase_auth_120dp.png
deleted file mode 100644
index 9b68e8ade..000000000
Binary files a/app/src/main/res/drawable-xxxhdpi/firebase_auth_120dp.png and /dev/null differ
diff --git a/app/src/main/res/drawable/custom_bg_gradient.xml b/app/src/main/res/drawable/custom_bg_gradient.xml
deleted file mode 100644
index 58479ea3c..000000000
--- a/app/src/main/res/drawable/custom_bg_gradient.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_anon_user_48dp.xml b/app/src/main/res/drawable/ic_anon_user_48dp.xml
deleted file mode 100644
index 0fb12013f..000000000
--- a/app/src/main/res/drawable/ic_anon_user_48dp.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_chat_message_arrow.xml b/app/src/main/res/drawable/ic_chat_message_arrow.xml
deleted file mode 100644
index 5063dc73e..000000000
--- a/app/src/main/res/drawable/ic_chat_message_arrow.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_chat_message_background.xml b/app/src/main/res/drawable/ic_chat_message_background.xml
deleted file mode 100644
index c22c3d71a..000000000
--- a/app/src/main/res/drawable/ic_chat_message_background.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_discord_24dp.xml b/app/src/main/res/drawable/ic_discord_24dp.xml
new file mode 100644
index 000000000..6b7ee0dae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_discord_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_googleg_color_144dp.xml b/app/src/main/res/drawable/ic_googleg_color_144dp.xml
deleted file mode 100644
index 820e04185..000000000
--- a/app/src/main/res/drawable/ic_googleg_color_144dp.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..1e4408cae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_line_logo_24dp.xml b/app/src/main/res/drawable/ic_line_logo_24dp.xml
new file mode 100644
index 000000000..b5402cc99
--- /dev/null
+++ b/app/src/main/res/drawable/ic_line_logo_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/auth_method_picker_custom_layout.xml b/app/src/main/res/layout-land/auth_method_picker_custom_layout.xml
deleted file mode 100644
index 52a234d53..000000000
--- a/app/src/main/res/layout-land/auth_method_picker_custom_layout.xml
+++ /dev/null
@@ -1,190 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_anonymous_upgrade.xml b/app/src/main/res/layout/activity_anonymous_upgrade.xml
deleted file mode 100644
index 946dfaf4b..000000000
--- a/app/src/main/res/layout/activity_anonymous_upgrade.xml
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml
deleted file mode 100644
index b03aa60c8..000000000
--- a/app/src/main/res/layout/activity_chat.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_chooser.xml b/app/src/main/res/layout/activity_chooser.xml
deleted file mode 100644
index 04ef02448..000000000
--- a/app/src/main/res/layout/activity_chooser.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_chooser_item.xml b/app/src/main/res/layout/activity_chooser_item.xml
deleted file mode 100644
index 57fdd2993..000000000
--- a/app/src/main/res/layout/activity_chooser_item.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_database_paging.xml b/app/src/main/res/layout/activity_database_paging.xml
deleted file mode 100644
index de654181c..000000000
--- a/app/src/main/res/layout/activity_database_paging.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_firestore_paging.xml b/app/src/main/res/layout/activity_firestore_paging.xml
deleted file mode 100644
index f9ac119ea..000000000
--- a/app/src/main/res/layout/activity_firestore_paging.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_image.xml b/app/src/main/res/layout/activity_image.xml
deleted file mode 100644
index 72d617bcd..000000000
--- a/app/src/main/res/layout/activity_image.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/auth_method_picker_custom_layout.xml b/app/src/main/res/layout/auth_method_picker_custom_layout.xml
deleted file mode 100644
index 7b2ab007a..000000000
--- a/app/src/main/res/layout/auth_method_picker_custom_layout.xml
+++ /dev/null
@@ -1,160 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/auth_ui_layout.xml b/app/src/main/res/layout/auth_ui_layout.xml
deleted file mode 100644
index 189fafa04..000000000
--- a/app/src/main/res/layout/auth_ui_layout.xml
+++ /dev/null
@@ -1,343 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_item.xml b/app/src/main/res/layout/item_item.xml
deleted file mode 100644
index 199abe810..000000000
--- a/app/src/main/res/layout/item_item.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/message.xml b/app/src/main/res/layout/message.xml
deleted file mode 100644
index bdbf05376..000000000
--- a/app/src/main/res/layout/message.xml
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/signed_in_layout.xml b/app/src/main/res/layout/signed_in_layout.xml
deleted file mode 100644
index 06c298a4c..000000000
--- a/app/src/main/res/layout/signed_in_layout.xml
+++ /dev/null
@@ -1,159 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_paging.xml b/app/src/main/res/menu/menu_paging.xml
deleted file mode 100644
index b2f2da4cb..000000000
--- a/app/src/main/res/menu/menu_paging.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 0c2a915e9..036d09bc5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,5 @@
-
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 0c2a915e9..036d09bc5 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,5 @@
-
+
-
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 04b550741..000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..b69b810b8
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
deleted file mode 100644
index 7e9b0a385..000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 000000000..522e8b49f
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 1d4f701ca..000000000
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..42ff206c7
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index a3363e785..000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..5e6619368
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
deleted file mode 100644
index 94fcac935..000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 000000000..aaaadd665
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index 4ba4452dd..000000000
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9f69d9f78
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index abb82e050..000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..590ed7e41
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
deleted file mode 100644
index ac42576fe..000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 000000000..e47c73a65
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index dcfad82dc..000000000
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..c3797b9df
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 025df8b10..000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..fee730f87
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index 506272a03..000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 000000000..d0d291335
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index 44ea258e7..000000000
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..a3c17df51
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index f64cb7c6b..000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..d6bcfe1bb
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
deleted file mode 100644
index ec5beec43..000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 000000000..125eace5d
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index 2d0841dc4..000000000
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..bb69db44c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-w820dp/dimens.xml b/app/src/main/res/values-w820dp/dimens.xml
deleted file mode 100644
index 63fc81644..000000000
--- a/app/src/main/res/values-w820dp/dimens.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- 64dp
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index a59a6e717..f8c6127d3 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,18 +1,10 @@
+
- #039BE5
- #0288D1
- #FFA000
-
-
- #E8F5E9
- #AED581
- #4CAF50
- #388E3C
- #AA00FF
-
- #E0E0E0
- #9e9e9e
-
- #FF5252
-
-
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml
deleted file mode 100644
index 514c0747d..000000000
--- a/app/src/main/res/values/config.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
- CHANGE-ME
-
- CHANGE-ME
- fbYOUR_APP_ID
-
- CHANGE-ME
- CHANGE-ME
-
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
deleted file mode 100644
index 47c822467..000000000
--- a/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- 16dp
- 16dp
-
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000..c5d5899fd
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 88d0ed1d8..515bb8e83 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,129 +1,10 @@
- Firebase UI
+ FirebaseUI Demo
-
- Auth UI Demo
- Cloud Firestore Demo
- Cloud Firestore Paging Demo
- Realtime Database Demo
- Realtime Database Paging Demo
- Storage Image Demo
+ flutterfire-e2e-tests.firebaseapp.com
- Demonstrates the Firebase Auth UI flow, with customization options.
- Demonstrates upgrading an anonymous account using FirebaseUI.
- Demonstrates using a FirestoreRecyclerAdapter to load data from Cloud Firestore into a RecyclerView for a basic chat app.
- Demonstrates using a FirestorePagingAdapter to load/infinite scroll paged data from Cloud Firestore.
- Demonstrates using a FirebaseRecyclerAdapter to load data from Firebase Database into a RecyclerView for a basic chat app.
- Demonstrates using a FirebaseRecyclerPagingAdapter to load/infinite scroll paged data from Firebase Realtime Database.
- Demonstrates displaying an image from Cloud Storage using Glide.
-
-
- FirebaseUI Auth Demo
- Start
- Sign in silently
-
- Auth providers
- Google
- Facebook
- Twitter
- Email
- Email link
- Phone
- Anonymous
- Apple
- Microsoft
- Yahoo
- GitHub
-
- Layout
- Default
- Custom
-
- Theme
- Material Light Theme
- App Theme
- Green Theme
-
- Logo
- Firebase Auth
- Google
- None
-
- Terms of Service and Privacy Policy
- Google
- Firebase
- None
-
- Example extra Google scopes
- Drive File
- Youtube data
-
- Example extra Facebook permissions
- Friends
- Photos
-
- Other Options
- Enable Credential Manager\'s credential selector
- Allow new account creation
- Require first/last name with email accounts.
- Connect to auth emulator (localhost:9099).
-
- Configuration required - see README.md
- Google configuration missing
- Facebook configuration missing
-
- Sign in cancelled
- No internet connection
- Account is disabled by the administrator
- An unknown error occurred
-
- FirebaseUI Anonymous Account Linking
- Anonymous Sign In
- Launch Auth UI
- Resolve Merge Conflict
-
-
- You are signed in!
- Sign out
- Delete account
-
- Sign in failed
- Sign out failed
- Delete account failed
-
- User profile:
- Profile picture
- Providers used: %s
- IDP Token
- IDP Secret
-
-
- Sign in using this nice custom layout!
- Choose a provider
- Use your email
- Continue with Email
-
-
-
- Choose Image
- Upload
- Download
- Send
- Downloaded image
-
- This sample will read an image from local storage to upload to Cloud Storage.
-
-
- No messages. Start chatting at the bottom!
- Signing in…
- Signed In
-
- Anonymous authentication failed, various components of the demo will not work.
- Make sure your device is online and that Anonymous Auth is configured in your Firebase project
- (https://console.firebase.google.com/project/_/authentication/providers)
-
-
- Add Data
- Say something…
- Reached End of List
-
+
+ 128693022464535
+ fb128693022464535
+ 16dbbdf0cfb309034a6ad98ac2a21688
+
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
deleted file mode 100644
index 9686a0eeb..000000000
--- a/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..1f225670b
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml
deleted file mode 100644
index 988b81d73..000000000
--- a/app/src/main/res/xml-v25/shortcuts.xml
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/auth/MIGRATION.md b/auth/MIGRATION.md
new file mode 100644
index 000000000..03f08085d
--- /dev/null
+++ b/auth/MIGRATION.md
@@ -0,0 +1,465 @@
+# Migration Guide: FirebaseUI Auth 9.x to 10.x
+
+This guide helps you migrate from FirebaseUI Auth 9.x (View-based) to 10.x (Compose-based).
+
+## Overview
+
+FirebaseUI Auth 10.x is a complete rewrite built with Jetpack Compose and Material Design 3. The architecture has changed significantly from the View-based system to a modern, declarative UI framework.
+
+## Key Architectural Changes
+
+### 1. UI Framework
+- **9.x**: Android Views, Activities, Fragments
+- **10.x**: Jetpack Compose, Composables
+
+### 2. Configuration
+- **9.x**: Builder pattern (`createSignInIntentBuilder()`)
+- **10.x**: Kotlin DSL (`authUIConfiguration {}`)
+
+### 3. Providers
+- **9.x**: `IdpConfig.EmailBuilder().build()`
+- **10.x**: `AuthProvider.Email()`
+
+### 4. Flow Control
+- **9.x**: Activity-based with `startActivityForResult()` and `ActivityResultLauncher`
+- **10.x**: Composable screens with direct callbacks OR `AuthFlowController` for Activity-based apps
+
+### 5. Theming
+- **9.x**: XML theme resources (`R.style.AppTheme`)
+- **10.x**: `AuthUITheme` with Material 3 color schemes
+
+### 6. State Management
+- **9.x**: `AuthStateListener` callbacks
+- **10.x**: Reactive `Flow`
+
+## Migration Steps
+
+### Step 1: Update Dependencies
+
+**Old (9.x):**
+```kotlin
+dependencies {
+ implementation("com.firebaseui:firebase-ui-auth:9.1.1")
+}
+```
+
+**New (10.x):**
+```kotlin
+dependencies {
+ // FirebaseUI Auth
+ implementation("com.firebaseui:firebase-ui-auth:10.0.0")
+
+ // Required: Jetpack Compose
+ implementation(platform("androidx.compose:compose-bom:2024.01.00"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.material3:material3")
+}
+```
+
+### Step 2: Migrate to Compose
+
+**Old (9.x) - Activity-based:**
+```java
+public class SignInActivity extends AppCompatActivity {
+ private final ActivityResultLauncher signInLauncher =
+ registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {
+ IdpResponse response = IdpResponse.fromResultIntent(result.getData());
+
+ if (result.getResultCode() == RESULT_OK) {
+ FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
+ // User signed in
+ } else {
+ // Sign in failed
+ }
+ }
+ );
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_signin);
+
+ Intent signInIntent = AuthUI.getInstance()
+ .createSignInIntentBuilder()
+ .setAvailableProviders(Arrays.asList(
+ new AuthUI.IdpConfig.EmailBuilder().build(),
+ new AuthUI.IdpConfig.GoogleBuilder().build()
+ ))
+ .setTheme(R.style.AppTheme)
+ .build();
+
+ signInLauncher.launch(signInIntent);
+ }
+}
+```
+
+**New (10.x) - Compose-based:**
+```kotlin
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ MyAppTheme {
+ val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Email(),
+ AuthProvider.Google()
+ )
+ theme = AuthUITheme.fromMaterialTheme()
+ }
+
+ FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result ->
+ val user = result.user
+ // User signed in
+ },
+ onSignInFailure = { exception ->
+ // Sign in failed
+ },
+ onSignInCancelled = {
+ finish()
+ }
+ )
+ }
+ }
+ }
+}
+```
+
+### Step 3: Update Provider Configuration
+
+**Old (9.x):**
+```java
+List providers = Arrays.asList(
+ new AuthUI.IdpConfig.EmailBuilder()
+ .setRequireName(true)
+ .build(),
+ new AuthUI.IdpConfig.GoogleBuilder()
+ .build(),
+ new AuthUI.IdpConfig.PhoneBuilder()
+ .build()
+);
+```
+
+**New (10.x):**
+```kotlin
+val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Email(
+ isDisplayNameRequired = true
+ ),
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "YOUR_CLIENT_ID"
+ ),
+ AuthProvider.Phone(
+ defaultCountryCode = "US"
+ )
+ )
+}
+```
+
+### Step 4: Update Theming
+
+**Old (9.x) - XML styles:**
+```xml
+
+```
+
+```java
+.setTheme(R.style.AppTheme)
+```
+
+**New (10.x) - Material 3:**
+```kotlin
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ theme = AuthUITheme(
+ colorScheme = lightColorScheme(
+ primary = Color(0xFF6200EE),
+ onPrimary = Color.White,
+ secondary = Color(0xFF03DAC6)
+ )
+ )
+}
+```
+
+Or inherit from your app theme:
+```kotlin
+MyAppTheme {
+ val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ theme = AuthUITheme.fromMaterialTheme()
+ }
+
+ FirebaseAuthScreen(configuration = configuration, ...)
+}
+```
+
+### Step 5: Update Sign Out
+
+**Old (9.x):**
+```java
+AuthUI.getInstance()
+ .signOut(this)
+ .addOnCompleteListener(task -> {
+ // User signed out
+ });
+```
+
+**New (10.x):**
+```kotlin
+lifecycleScope.launch {
+ FirebaseAuthUI.getInstance().signOut(context)
+ // User signed out
+}
+```
+
+### Step 6: Update Account Deletion
+
+**Old (9.x):**
+```java
+AuthUI.getInstance()
+ .delete(this)
+ .addOnCompleteListener(task -> {
+ if (task.isSuccessful()) {
+ // Account deleted
+ } else {
+ // Deletion failed
+ }
+ });
+```
+
+**New (10.x):**
+```kotlin
+lifecycleScope.launch {
+ try {
+ FirebaseAuthUI.getInstance().delete(context)
+ // Account deleted
+ } catch (e: Exception) {
+ // Deletion failed
+ }
+}
+```
+
+### Step 7: Auth State Observation
+
+**Old (9.x):**
+```java
+FirebaseAuth.getInstance().addAuthStateListener(firebaseAuth -> {
+ FirebaseUser user = firebaseAuth.getCurrentUser();
+ if (user != null) {
+ // User is signed in
+ } else {
+ // User is signed out
+ }
+});
+```
+
+**New (10.x):**
+```kotlin
+@Composable
+fun AuthGate() {
+ val authUI = remember { FirebaseAuthUI.getInstance() }
+ val authState by authUI.authStateFlow().collectAsState(initial = AuthState.Idle)
+
+ when (authState) {
+ is AuthState.Success -> {
+ // User is signed in
+ MainAppScreen()
+ }
+ else -> {
+ // Show authentication
+ FirebaseAuthScreen(...)
+ }
+ }
+}
+```
+
+## Provider-Specific Migration
+
+### Email Provider
+
+**Old (9.x):**
+```java
+new AuthUI.IdpConfig.EmailBuilder()
+ .setRequireName(true)
+ .setAllowNewAccounts(true)
+ .enableEmailLinkSignIn()
+ .setActionCodeSettings(actionCodeSettings)
+ .build()
+```
+
+**New (10.x):**
+```kotlin
+AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isNewAccountsAllowed = true,
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://example.com/auth"
+ handleCodeInApp = true
+ setAndroidPackageName(packageName, true, null)
+ }
+)
+```
+
+### Google Provider
+
+**Old (9.x):**
+```java
+new AuthUI.IdpConfig.GoogleBuilder()
+ .setScopes(Arrays.asList("email", "profile"))
+ .build()
+```
+
+**New (10.x):**
+```kotlin
+AuthProvider.Google(
+ scopes = listOf("email", "profile"),
+ serverClientId = "YOUR_CLIENT_ID"
+)
+```
+
+### Phone Provider
+
+**Old (9.x):**
+```java
+new AuthUI.IdpConfig.PhoneBuilder()
+ .setDefaultNumber("US", "+1 123-456-7890")
+ .build()
+```
+
+**New (10.x):**
+```kotlin
+AuthProvider.Phone(
+ defaultCountryCode = "US",
+ defaultNumber = "+11234567890"
+)
+```
+
+## Advanced Migration Scenarios
+
+### Custom UI (Activity-based apps that can't use Compose everywhere)
+
+If you have an existing Activity-based app and want to keep using Activities:
+
+**New (10.x) - Low-Level API:**
+```kotlin
+class AuthActivity : ComponentActivity() {
+ private lateinit var controller: AuthFlowController
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email(), AuthProvider.Google())
+ }
+
+ controller = authUI.createAuthFlow(configuration)
+
+ lifecycleScope.launch {
+ val state = controller.start()
+ when (state) {
+ is AuthState.Success -> {
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ }
+ is AuthState.Error -> {
+ // Handle error
+ }
+ else -> {
+ // Handle other states
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ controller.dispose()
+ }
+}
+```
+
+## Common Issues and Solutions
+
+### Issue: "Unresolved reference: authUIConfiguration"
+
+**Solution:** Ensure you have the correct import:
+```kotlin
+import com.firebase.ui.auth.configuration.authUIConfiguration
+```
+
+### Issue: "ActivityResultLauncher is deprecated"
+
+**Solution:** In 10.x, you no longer need `ActivityResultLauncher`. Use direct callbacks with `FirebaseAuthScreen` or `AuthFlowController`.
+
+### Issue: "How do I customize the UI?"
+
+**Solution:** Use content slots for custom UI:
+```kotlin
+EmailAuthScreen(
+ configuration = emailConfig,
+ onSuccess = { /* ... */ },
+ onError = { /* ... */ },
+ onCancel = { /* ... */ }
+) { state ->
+ // Your custom UI here
+ CustomSignInUI(state)
+}
+```
+
+### Issue: "My XML themes aren't working"
+
+**Solution:** Convert XML themes to Kotlin code using `AuthUITheme`:
+```kotlin
+val configuration = authUIConfiguration {
+ theme = AuthUITheme(
+ colorScheme = lightColorScheme(
+ primary = Color(0xFF6200EE),
+ // ... other colors
+ )
+ )
+}
+```
+
+## Testing Your Migration
+
+1. **Build the app** - Ensure it compiles without errors
+2. **Test all auth flows** - Sign in, sign up, password reset
+3. **Test all providers** - Email, Google, Phone, etc.
+4. **Test sign out** - Verify users can sign out
+5. **Test account deletion** - Verify accounts can be deleted
+6. **Test error handling** - Verify errors are handled gracefully
+7. **Test theming** - Verify UI matches your design
+
+## Checklist
+
+- [ ] Updated dependency to `firebase-ui-auth:10.0.0`
+- [ ] Migrated to Jetpack Compose
+- [ ] Converted Activities to ComponentActivities with `setContent {}`
+- [ ] Replaced `createSignInIntentBuilder()` with `authUIConfiguration {}`
+- [ ] Updated all provider configurations
+- [ ] Converted XML themes to `AuthUITheme`
+- [ ] Updated error handling from result codes to exceptions
+- [ ] Removed `ActivityResultLauncher` code
+- [ ] Updated sign-out to use suspend functions
+- [ ] Updated account deletion to use suspend functions
+- [ ] Tested all authentication flows
+- [ ] Tested on multiple Android versions
+
+## Need Help?
+
+- [FirebaseUI Auth Documentation](https://github.com/firebase/FirebaseUI-Android/blob/master/auth/README.md)
+- [GitHub Issues](https://github.com/firebase/FirebaseUI-Android/issues)
+- [Stack Overflow](https://stackoverflow.com/questions/tagged/firebaseui)
diff --git a/auth/README.md b/auth/README.md
index b705b30d0..4e7dbdfb8 100644
--- a/auth/README.md
+++ b/auth/README.md
@@ -1,815 +1,1468 @@
# FirebaseUI for Auth
-FirebaseUI is an open-source library that offers simple,
-customizable UI bindings on top of the core
-[Firebase](https://firebase.google.com) SDKs. It aims to eliminate boilerplate
-code and promote best practices (both user experience and security) for
-authentication.
-
-A simple API is provided for drop-in user authentication which handles the flow
-of signing in users with email addresses and passwords, phone numbers, and federated identity
-providers such as Google Sign-In, and Facebook Login. It is built on top of
-[Firebase Auth](https://firebase.google.com/docs/auth).
-
-The best practices embodied in FirebaseUI aim to maximize sign-in
-and sign-up conversion for your app. It integrates with
-[Credential Manager](https://developer.android.com/identity/sign-in/credential-manager)
-to store and retrieve credentials, enabling automatic and single-tap sign-in to
-your app for returning users. It also handles tricky use cases like
-account recovery and account linking that are security sensitive and
-difficult to implement correctly using the base APIs provided by Firebase Auth.
-
-FirebaseUI auth can be easily customized to fit with the rest of your app's
-visual style. As it is open source, you are also free to modify it to exactly
-fit your preferred user experience.
-
-Equivalent FirebaseUI auth libraries are also available for
-[iOS](https://github.com/firebase/firebaseui-ios/)
-and [Web](https://github.com/firebase/firebaseui-web/).
-
-## Table of contents
+FirebaseUI Auth is a modern, Compose-based authentication library that provides drop-in UI components for Firebase Authentication. It eliminates boilerplate code and promotes best practices for user authentication on Android.
+
+Built entirely with **Jetpack Compose** and **Material Design 3**, FirebaseUI Auth offers:
+
+- **Simple API** - Choose between high-level screens or low-level controllers for maximum flexibility
+- **12+ Authentication Methods** - Email/Password, Phone, Google, Facebook, Twitter, GitHub, Microsoft, Yahoo, Apple, Anonymous, and custom OAuth providers
+- **Multi-Factor Authentication** - SMS and TOTP (Time-based One-Time Password) with recovery codes
+- **Android Credential Manager** - Automatic credential saving and one-tap sign-in
+- **Material Design 3** - Beautiful, themeable UI components that integrate seamlessly with your app
+- **Localization Support** - Customizable strings for internationalization
+- **Security Best Practices** - Email verification, reauthentication, account linking, and more
+
+Equivalent FirebaseUI libraries are available for [iOS](https://github.com/firebase/firebaseui-ios/) and [Web](https://github.com/firebase/firebaseui-web/).
+
+## Table of Contents
1. [Demo](#demo)
-1. [Configuration](#configuration)
- 1. [Basics](#basics)
- 1. [Themes](#themes)
- 1. [Provider config](#identity-provider-configuration)
- 1. [Auth emulator config](#auth-emulator-configuration)
-1. [Usage instructions](#using-firebaseui-for-authentication)
- 1. [AuthUI sign-in](#authui-sign-in)
- 1. [Handling responses](#handling-the-sign-in-response)
- 1. [Sign out](#sign-out)
- 1. [Account deletion](#deleting-accounts)
- 1. [Upgrading Anonymous Users](#upgrading-anonymous-users)
-1. [UI Customization](#ui-customization)
- 1. [Custom layout](#custom-layout)
- 1. [Strings](#strings)
-1. [OAuth scopes](#oauth-scope-customization)
- 1. [Google](#google-1)
- 1. [Facebook](#facebook-1)
- 1. [Twitter](#twitter-1)
+1. [Setup](#setup)
+ 1. [Prerequisites](#prerequisites)
+ 1. [Installation](#installation)
+ 1. [Provider Configuration](#provider-configuration)
+1. [Quick Start](#quick-start)
+ 1. [Minimal Example](#minimal-example)
+ 1. [Check Authentication State](#check-authentication-state)
+1. [Core Concepts](#core-concepts)
+ 1. [FirebaseAuthUI](#firebaseauthui)
+ 1. [AuthUIConfiguration](#authuiconfiguration)
+ 1. [AuthFlowController](#authflowcontroller)
+ 1. [AuthState](#authstate)
+1. [Authentication Methods](#authentication-methods)
+ 1. [Email & Password](#email--password)
+ 1. [Phone Number](#phone-number)
+ 1. [Google Sign-In](#google-sign-in)
+ 1. [Facebook Login](#facebook-login)
+ 1. [Other OAuth Providers](#other-oauth-providers)
+ 1. [Anonymous Authentication](#anonymous-authentication)
+ 1. [Custom OAuth Provider](#custom-oauth-provider)
+1. [Usage Patterns](#usage-patterns)
+ 1. [High-Level API (Recommended)](#high-level-api-recommended)
+ 1. [Low-Level API (Advanced)](#low-level-api-advanced)
+ 1. [Custom UI with Slots](#custom-ui-with-slots)
+1. [Multi-Factor Authentication](#multi-factor-authentication)
+ 1. [MFA Configuration](#mfa-configuration)
+ 1. [MFA Enrollment](#mfa-enrollment)
+ 1. [MFA Challenge](#mfa-challenge)
+1. [Theming & Customization](#theming--customization)
+ 1. [Material Theme Integration](#material-theme-integration)
+ 1. [Custom Theme](#custom-theme)
+ 1. [Provider Button Styling](#provider-button-styling)
+1. [Advanced Features](#advanced-features)
+ 1. [Anonymous User Upgrade](#anonymous-user-upgrade)
+ 1. [Email Link Sign-In](#email-link-sign-in)
+ 1. [Password Validation Rules](#password-validation-rules)
+ 1. [Credential Manager Integration](#credential-manager-integration)
+ 1. [Sign Out & Account Deletion](#sign-out--account-deletion)
+1. [Localization](#localization)
+1. [Error Handling](#error-handling)
+1. [Migration Guide](#migration-guide)
## Demo
-
+
+
+## Setup
-## Configuration
+### Prerequisites
-As a pre-requisite, ensure your application is configured for use with
-Firebase: see the
-[Firebase documentation](https://firebase.google.com/docs/android/setup).
-Then, add the FirebaseUI auth library dependency. If your project uses
-Gradle, add the dependency:
+Ensure your application is configured for use with Firebase. See the [Firebase documentation](https://firebase.google.com/docs/android/setup) for setup instructions.
-```groovy
+**Minimum Requirements:**
+- Android SDK 21+ (Android 5.0 Lollipop)
+- Kotlin 1.9+
+- Jetpack Compose (Compiler 1.5+)
+- Firebase Auth 22.0.0+
+
+### Installation
+
+Add the FirebaseUI Auth library dependency to your `build.gradle.kts` (Module):
+
+```kotlin
dependencies {
- // ...
- implementation 'com.firebaseui:firebase-ui-auth:9.0.0'
+ // FirebaseUI for Auth
+ implementation("com.firebaseui:firebase-ui-auth:10.0.0")
+
+ // Required: Firebase Auth
+ implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
+ implementation("com.google.firebase:firebase-auth")
- // Required only if Facebook login support is required
- // Find the latest Facebook SDK releases here: https://github.com/facebook/facebook-android-sdk/blob/master/CHANGELOG.md
- implementation 'com.facebook.android:facebook-login:8.1.0'
+ // Required: Jetpack Compose
+ implementation(platform("androidx.compose:compose-bom:2024.01.00"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.material3:material3")
+
+ // Optional: Facebook Login (if using FacebookAuthProvider)
+ implementation("com.facebook.android:facebook-login:16.3.0")
}
```
-FirebaseUI includes translations for all string resources. In order to
-ensure that you only get the translations relevant to your application, we recommend changing the
-`resConfigs` of your application module:
+**Localization Support:**
-```groovy
-android {
- // ...
+To optimize APK size, configure resource filtering for only the languages your app supports:
+```kotlin
+android {
defaultConfig {
- // ...
- resConfigs "en" // And any other languages you support
+ resourceConfigurations += listOf("en", "es", "fr") // Add your supported languages
}
}
```
-See the [Android documentation](https://developer.android.com/studio/build/shrink-code.html#unused-alt-resources)
-for more information.
-
-### Basics
+### Provider Configuration
-There are three main steps to adding FirebaseUI in your app:
+#### Google Sign-In
- 1. Build a sign in `Intent` using `AuthUI#createSignInIntentBuilder()`
- 2. Launch the `Intent` using an `ActivityResultLauncher`
- 3. Handle the result.
-
-```java
-private ActivityResultLauncher signInLauncher = registerForActivityResult(
- new FirebaseAuthUIActivityResultContract(),
- (result) -> {
- // Handle the FirebaseAuthUIAuthenticationResult
- // ...
- });
+Google Sign-In configuration is automatically provided by the [google-services Gradle plugin](https://developers.google.com/android/guides/google-services-plugin). Ensure you have enabled Google Sign-In in the [Firebase Console](https://console.firebase.google.com/project/_/authentication/providers).
-// ...
+#### Facebook Login
-private void startSignIn() {
- Intent signInIntent = AuthUI.getInstance()
- .createSignInIntentBuilder()
- // ... options ...
- .build();
+If using Facebook Login, add your Facebook App ID to `strings.xml`:
- signInLauncher.launch(signInIntent);
-}
+```xml
+
+ YOUR_FACEBOOK_APP_ID
+ fbYOUR_FACEBOOK_APP_ID
+
```
-### Themes
+See the [Facebook for Developers](https://developers.facebook.com/) documentation for setup instructions.
-As of version `8.0.0` FirebaseUI uses Material Design Components and themes. To use FirebaseUI seamlessly in your app you should provide a theme resource which provides Material Design color attributes ([read more here](https://material.io/blog/android-material-theme-color)).
+#### Other Providers
-At a minimum your theme should define the following attributes:
+Twitter, GitHub, Microsoft, Yahoo, and Apple providers require configuration in the Firebase Console but no additional Android-specific setup. See the [Firebase Auth documentation](https://firebase.google.com/docs/auth) for provider-specific instructions.
- * `colorPrimary`
- * `colorPrimaryVariant`
- * `colorAccent`
- * `android:statusBarColor` (API > 21) or `colorPrimaryDark` (API < 21)
+## Quick Start
-#### Using your app theme
+### Minimal Example
-To configure FirebaseUI to match your app's exising theme, simply pass your main theme attribute to `setTheme()`:
+Here's the simplest way to add authentication to your app with Email and Google Sign-In:
-This would then be used in the construction of the sign-in intent:
+```kotlin
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
-```java
-Intent signInIntent =
- AuthUI.getInstance(this).createSignInIntentBuilder()
- // ...
- .setTheme(R.style.AppTheme)
- .build())
+ setContent {
+ MyAppTheme {
+ val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Email(),
+ AuthProvider.Google()
+ )
+ }
+
+ FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result ->
+ Toast.makeText(this, "Welcome!", Toast.LENGTH_SHORT).show()
+ // Navigate to main app screen
+ },
+ onSignInFailure = { exception ->
+ Toast.makeText(this, "Error: ${exception.message}", Toast.LENGTH_SHORT).show()
+ },
+ onSignInCancelled = {
+ finish()
+ }
+ )
+ }
+ }
+ }
+}
```
-#### Using a custom theme
+That's it! This provides a complete authentication flow with:
+- ✅ Email/password sign-in and sign-up
+- ✅ Google Sign-In
+- ✅ Password reset
+- ✅ Display name collection
+- ✅ Credential Manager integration
+- ✅ Material Design 3 theming
+- ✅ Error handling
+
+### Check Authentication State
+
+Before showing the authentication UI, check if a user is already signed in:
+
+```kotlin
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val authUI = FirebaseAuthUI.getInstance()
+
+ if (authUI.isSignedIn()) {
+ // User is already signed in, navigate to main app
+ startActivity(Intent(this, MainAppActivity::class.java))
+ finish()
+ } else {
+ // Show authentication UI
+ setContent {
+ FirebaseAuthScreen(/* ... */)
+ }
+ }
+ }
+}
+```
-For example, here is a simple green sign-in theme:
+Or observe authentication state changes reactively:
-```xml
-
+ when {
+ authState is AuthState.Success -> {
+ // User is signed in
+ MainAppScreen()
+ }
+ else -> {
+ // Show authentication
+ FirebaseAuthScreen(/* ... */)
+ }
+ }
+}
```
-With associated colors:
+## Core Concepts
-```xml
-#E8F5E9
-#4CAF50
-#388E3C
-#AA00FF
-```
+### FirebaseAuthUI
-This would then be used in the construction of the sign-in intent:
+`FirebaseAuthUI` is the central class that coordinates all authentication operations. It manages UI state and provides methods for signing in, signing up, and managing user accounts.
-```java
-Intent signinIntent =
- AuthUI.getInstance(this).createSignInIntentBuilder()
- // ...
- .setTheme(R.style.GreenTheme)
- .build();
+```kotlin
+// Get the default instance
+val authUI = FirebaseAuthUI.getInstance()
+
+// Or get an instance for a specific Firebase app
+val customApp = Firebase.app("secondary")
+val authUI = FirebaseAuthUI.getInstance(customApp)
+
+// Or create with custom auth (for multi-tenancy)
+val customAuth = Firebase.auth(customApp)
+val authUI = FirebaseAuthUI.create(auth = customAuth)
```
-### Identity provider configuration
+**Key Methods:**
-In order to use either Google, Facebook, Twitter, Microsoft, Apple, GitHub or Yahoo accounts with
-your app, ensure that these authentication methods are first configured in the Firebase console.
+| Method | Return Type | Description |
+|--------|-------------|-------------|
+| `isSignedIn()` | `Boolean` | Checks if a user is currently signed in |
+| `getCurrentUser()` | `FirebaseUser?` | Returns the current user, if signed in |
+| `authStateFlow()` | `Flow` | Observes authentication state changes |
+| `createAuthFlow(config)` | `AuthFlowController` | Creates a sign-in flow controller |
+| `signOut(context)` | `suspend fun` | Signs out the current user |
+| `delete(context)` | `suspend fun` | Deletes the current user account |
-#### Google
+### AuthUIConfiguration
-FirebaseUI client-side configuration for Google sign-in is then provided
-automatically by the
-[google-services gradle plugin](https://developers.google.com/android/guides/google-services-plugin).
+`AuthUIConfiguration` defines all settings for your authentication flow. Use the DSL builder function for easy configuration:
-#### Facebook
+```kotlin
+val configuration = authUIConfiguration {
+ // Required: List of authentication providers
+ providers = listOf(
+ AuthProvider.Email(),
+ AuthProvider.Google(),
+ AuthProvider.Phone()
+ )
-If support for Facebook Login is also required, define the
-resource string `facebook_application_id` to match the application ID in
-the [Facebook developer dashboard](https://developers.facebook.com):
+ // Optional: Theme configuration
+ theme = AuthUITheme.fromMaterialTheme()
-```xml
-
-
- APP_ID
-
- fbAPP_ID
-
-```
+ // Optional: Terms of Service and Privacy Policy URLs
+ tosUrl = "https://example.com/terms"
+ privacyPolicyUrl = "https://example.com/privacy"
-#### Microsoft, Apple, Twitter, GitHub and Yahoo
+ // Optional: App logo
+ logo = Icons.Default.AccountCircle
-No FirebaseUI configuration is required for these providers.
+ // Optional: Enable MFA (default: true)
+ isMfaEnabled = true
-We support the use of scopes and custom parameters for these providers. For example:
+ // Optional: Enable Credential Manager (default: true)
+ isCredentialManagerEnabled = true
-```java
-List scopes =
- new ArrayList() {
- {
- add("mail.read");
- add("calendars.read");
- }
- };
+ // Optional: Allow anonymous user upgrade (default: false)
+ isAnonymousUpgradeEnabled = true
-Map customParams = new HashMap<>();
-customParams.put("tenant", "TENANT_ID");
+ // Optional: Require display name on sign-up (default: true)
+ isDisplayNameRequired = true
-IdpConfig microsoftConfig = new IdpConfig.MicrosoftBuilder()
- .setScopes(scopes)
- .setCustomParameters(customParams)
- .build();
-selectedProviders.add(microsoftConfig);
-```
+ // Optional: Allow new email accounts (default: true)
+ isNewEmailAccountsAllowed = true
-Note: unlike other sign-in methods, signing in with these providers involves the use of a
-[Custom Chrome Tab](https://developer.chrome.com/multidevice/android/customtabs).
+ // Optional: Always show provider choice even with one provider (default: false)
+ isProviderChoiceAlwaysShown = false
-##### Twitter
+ // Optional: Custom string provider for localization
+ stringProvider = MyCustomStringProvider()
-You must enable the "Request email addresses from users" permission in the "Permissions" tab of your
-Twitter app.
+ // Optional: Locale override
+ locale = Locale.FRENCH
+}
+```
-### Auth emulator configuration
+### AuthFlowController
-As of version `7.0.0` FirebaseUI is compatible with the Firebase Authentication emulator:
-https://firebase.google.com/docs/emulator-suite
+`AuthFlowController` manages the lifecycle of an authentication flow programmatically. This is the low-level API for advanced use cases.
-Use the `useEmulator` method to point an AuthUI instance at the emulator:
+```kotlin
+val controller = authUI.createAuthFlow(configuration)
-```java
-AuthUI authUI = AuthUI.getInstance();
+lifecycleScope.launch {
+ // Start the flow
+ val state = controller.start()
-// "10.0.2.2" is the special host value for contacting "localhost" from within
-// the Android Emulator
-authUI.useEmulator("10.0.2.2", 9099);
-```
+ when (state) {
+ is AuthState.Success -> {
+ // Handle success
+ val user = state.result.user
+ }
+ is AuthState.Error -> {
+ // Handle error
+ Log.e(TAG, "Auth failed", state.exception)
+ }
+ is AuthState.Cancelled -> {
+ // User cancelled
+ }
+ else -> {
+ // Handle other states (RequiresMfa, RequiresEmailVerification, etc.)
+ }
+ }
+}
+
+// Cancel the flow if needed
+controller.cancel()
-By default Android blocks connections to `http://` endpoints such as the Auth emulator.
-To allow your app to communicate with the Auth emulator, use a [network security configuration](https://developer.android.com/training/articles/security-config)
-or set `android:usesCleartextTraffic="true"` in `AndroidManifest.xml`.
+// Clean up when done
+override fun onDestroy() {
+ super.onDestroy()
+ controller.dispose()
+}
+```
-## Using FirebaseUI for authentication
+### AuthState
-Before invoking the FirebaseUI authentication flow, your app should check
-whether a
-[user is already signed in](https://firebase.google.com/docs/auth/android/manage-users#get_the_currently_signed-in_user)
-from a previous session:
+`AuthState` represents the current state of authentication:
-```java
-FirebaseAuth auth = FirebaseAuth.getInstance();
-if (auth.getCurrentUser() != null) {
- // already signed in
-} else {
- // not signed in
+```kotlin
+sealed class AuthState {
+ object Idle : AuthState()
+ data class Loading(val message: String?) : AuthState()
+ data class Success(val result: AuthResult, val isNewUser: Boolean) : AuthState()
+ data class Error(val exception: AuthException, val isRecoverable: Boolean) : AuthState()
+ data class RequiresMfa(val resolver: MultiFactorResolver) : AuthState()
+ data class RequiresEmailVerification(val user: FirebaseUser) : AuthState()
+ data class RequiresProfileCompletion(val user: FirebaseUser) : AuthState()
+ object Cancelled : AuthState()
}
```
-The entry point to the authentication flow is the
-`com.firebase.ui.auth.AuthUI` class.
-If your application uses the default `FirebaseApp` instance, an AuthUI
-instance can be retrieved simply by calling `AuthUI.getInstance()`.
-If an alternative app instance is required, call
-`AuthUI.getInstance(app)` instead, passing the appropriate `FirebaseApp`
-instance.
+## Authentication Methods
-### AuthUI sign-in
+### Email & Password
-If a user is not currently signed in, as can be determined by checking
-`auth.getCurrentUser() != null` (where `auth` is the `FirebaseAuth` instance
-associated with your `FirebaseApp`), then the sign-in process can be started by
-creating a sign-in intent using `AuthUI.SignInIntentBuilder`. A builder instance
-can be retrieved by calling `createSignInIntentBuilder()` on the retrieved
-AuthUI instance.
+Configure email/password authentication with optional customization:
-The builder provides the following customization options for the authentication flow:
+```kotlin
+val emailProvider = AuthProvider.Email(
+ // Optional: Require display name (default: true)
+ isDisplayNameRequired = true,
-* The set of authentication providers can be specified.
-* The terms of service URL for your app can be specified, which is included as
- a link in the small-print of the account creation step for new users. If no
- terms of service URL is provided, the associated small-print is omitted.
-* A custom theme can be specified for the flow, which is applied to all the
- activities in the flow for consistent colors and typography.
+ // Optional: Enable email link sign-in (default: false)
+ isEmailLinkSignInEnabled = true,
-#### Sign-in examples
+ // Optional: Force email link on same device (default: true)
+ isEmailLinkForceSameDeviceEnabled = true,
-If no customization is required, and only email authentication is required, the sign-in flow
-can be started as follows:
+ // Optional: Action code settings for email link
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://example.com/auth"
+ handleCodeInApp = true
+ setAndroidPackageName(packageName, true, null)
+ },
-```java
-// Get an instance of AuthUI based on the default app
-Intent signInIntent =
- AuthUI.getInstance().createSignInIntentBuilder().build();
+ // Optional: Allow new accounts (default: true)
+ isNewAccountsAllowed = true,
-signInLauncher.launch(signInIntent);
-```
+ // Optional: Minimum password length (default: 6)
+ minimumPasswordLength = 8,
-To kick off the FirebaseUI sign in flow, use an `ActivityResultLauncher` to launch the Intent you built.
-See the [response codes](#response-codes) section below for more details on receiving the results of the sign in flow.
+ // Optional: Custom password validation rules
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireDigit,
+ PasswordRule.RequireSpecialCharacter
+ )
+)
-##### Adding providers
+val configuration = authUIConfiguration {
+ providers = listOf(emailProvider)
+}
+```
-You can enable sign-in providers like Google Sign-In or Facebook Log In by calling the
-`setAvailableProviders` method:
+### Phone Number
-```java
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setAvailableProviders(Arrays.asList(
- new AuthUI.IdpConfig.GoogleBuilder().build(),
- new AuthUI.IdpConfig.FacebookBuilder().build(),
- new AuthUI.IdpConfig.TwitterBuilder().build(),
- new AuthUI.IdpConfig.MicrosoftBuilder().build(),
- new AuthUI.IdpConfig.YahooBuilder().build(),
- new AuthUI.IdpConfig.AppleBuilder().build(),
- new AuthUI.IdpConfig.EmailBuilder().build(),
- new AuthUI.IdpConfig.PhoneBuilder().build(),
- new AuthUI.IdpConfig.AnonymousBuilder().build()))
- .build();
-```
-
-##### Configuring Email Link Sign In
-
-To use email link sign in, you will first need to enable it in the Firebase Console. Additionally, you will
-also have to enable Firebase Dynamic Links.
-
-You can enable email link sign in by calling the `enableEmailLinkSignIn` on an `EmailBuilder` instance. You will also need
-to provide a valid `ActionCodeSettings` object with `setHandleCodeInApp` set to true. Additionally, you need to allowlist the
-URL you pass to `setUrl`; you can do so in the Firebase Console (Authentication -> Sign in Methods -> Authorized domains).
+Configure phone number authentication with SMS verification:
-```java
+```kotlin
+val phoneProvider = AuthProvider.Phone(
+ // Optional: Default phone number in international format
+ defaultNumber = "+15551234567",
-ActionCodeSettings actionCodeSettings = ActionCodeSettings.newBuilder()
- .setAndroidPackageName(/*yourPackageName*/, /*installIfNotAvailable*/true, /*minimumVersion*/null)
- .setHandleCodeInApp(true)
- .setUrl("https://google.com") // This URL needs to be allowlisted
- .build();
+ // Optional: Default country code (ISO alpha-2 format)
+ defaultCountryCode = "US",
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setAvailableProviders(Arrays.asList(
- new AuthUI.IdpConfig.EmailBuilder().enableEmailLinkSignIn()
- .setActionCodeSettings(actionCodeSettings).build())
- .build();
-```
+ // Optional: Allowed countries
+ allowedCountries = listOf("US", "CA", "GB"),
-If you want to catch the link in a specific activity, please follow the steps outlined [here](https://firebase.google.com/docs/auth/android/email-link-auth).
-Otherwise, the link will redirect to your launcher activity.
+ // Optional: SMS code length (default: 6)
+ smsCodeLength = 6,
-Once you catch the deep link, you will need to call verify that we can handle it for you. If we can, you need to then
-pass it to us via `setEmailLink`.
+ // Optional: Timeout for SMS delivery in seconds (default: 60)
+ timeout = 60L,
-```java
-if (AuthUI.canHandleIntent(getIntent())) {
- if (getIntent().getExtras() == null) {
- return;
- }
- String link = getIntent().getData().toString();
- if (link != null) {
- Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setEmailLink(link)
- .setAvailableProviders(getAvailableProviders())
- .build();
- signInLauncher.launch(signInIntent);
- }
+ // Optional: Enable instant verification (default: true)
+ isInstantVerificationEnabled = true
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(phoneProvider)
}
```
-#### Cross device support
+### Google Sign-In
-We support cross device email link sign in for the normal flows. It is not supported with anonymous user upgrade. By default,
-cross device support is enabled. You can disable it by calling `setForceSameDevice` on the `EmailBuilder` instance.
+Configure Google Sign-In with optional scopes and server client ID:
-##### Adding a ToS and privacy policy
+```kotlin
+val googleProvider = AuthProvider.Google(
+ // Required: Scopes to request
+ scopes = listOf("https://www.googleapis.com/auth/drive.file"),
-A terms of service URL and privacy policy URL are generally required:
+ // Optional: Server client ID for backend authentication
+ serverClientId = "YOUR_SERVER_CLIENT_ID.apps.googleusercontent.com",
-```java
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setAvailableProviders(...)
- .setTosAndPrivacyPolicyUrls("https://superapp.example.com/terms-of-service.html",
- "https://superapp.example.com/privacy-policy.html")
- .build();
+ // Optional: Custom OAuth parameters
+ customParameters = mapOf("prompt" to "select_account")
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(googleProvider)
+}
```
-##### Credential Manager
+### Facebook Login
-By default, FirebaseUI uses [Credential Manager](https://developer.android.com/identity/sign-in/credential-manager)
-to store the user's credentials and automatically sign users into your app on subsequent attempts.
-Using Credential Manager is recommended to provide the best user experience, but in some cases you may want
-to disable Credential Manager for testing or development. To disable Credential Manager, you can use the
-`setCredentialManagerEnabled` method when building your sign-in Intent:
+Configure Facebook Login with optional permissions:
-```java
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setCredentialManagerEnabled(false)
- .build();
-```
+```kotlin
+val facebookProvider = AuthProvider.Facebook(
+ // Optional: Facebook application ID (reads from strings.xml if not provided)
+ applicationId = "YOUR_FACEBOOK_APP_ID",
-##### Phone number authentication customization
+ // Optional: Permissions to request (default: ["email", "public_profile"])
+ scopes = listOf("email", "public_profile", "user_friends"),
-###### Setting a default phone number
-When using the phone verification provider and the number is known in advance, it is possible to
-provide a default phone number (in international format) that will be used to prepopulate the
-country code and phone number input fields. The user is still able to edit the number if desired.
+ // Optional: Custom OAuth parameters
+ customParameters = mapOf("display" to "popup")
+)
-```java
-IdpConfig phoneConfigWithDefaultNumber = new IdpConfig.PhoneBuilder()
- .setDefaultNumber("+123456789")
- .build();
+val configuration = authUIConfiguration {
+ providers = listOf(facebookProvider)
+}
```
-Alternatively, you can set the default country (alpha-2 format) to be shown in the country selector.
-
-```java
-IdpConfig phoneConfigWithDefaultNumber = new IdpConfig.PhoneBuilder()
- .setDefaultCountryIso("ca")
- .build();
+### Other OAuth Providers
+
+FirebaseUI supports Twitter, GitHub, Microsoft, Yahoo, and Apple:
+
+```kotlin
+// Twitter
+val twitterProvider = AuthProvider.Twitter(
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("lang" to "en")
+)
+
+// GitHub
+val githubProvider = AuthProvider.Github(
+ // Optional: Scopes to request (default: ["user:email"])
+ scopes = listOf("user:email", "read:user"),
+
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("allow_signup" to "false")
+)
+
+// Microsoft
+val microsoftProvider = AuthProvider.Microsoft(
+ // Optional: Scopes to request (default: ["openid", "profile", "email"])
+ scopes = listOf("openid", "profile", "email", "User.Read"),
+
+ // Optional: Tenant ID for Azure Active Directory
+ tenant = "YOUR_TENANT_ID",
+
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("prompt" to "consent")
+)
+
+// Yahoo
+val yahooProvider = AuthProvider.Yahoo(
+ // Optional: Scopes to request (default: ["openid", "profile", "email"])
+ scopes = listOf("openid", "profile", "email"),
+
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("language" to "en-us")
+)
+
+// Apple
+val appleProvider = AuthProvider.Apple(
+ // Optional: Scopes to request (default: ["name", "email"])
+ scopes = listOf("name", "email"),
+
+ // Optional: Locale for the sign-in page
+ locale = "en_US",
+
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("ui_locales" to "en-US")
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(
+ twitterProvider,
+ githubProvider,
+ microsoftProvider,
+ yahooProvider,
+ appleProvider
+ )
+}
```
-It is also possible to set a default country code along with a national number if a specific country
-is your app's target audience. This will take precedence over the full default phone number if both
-are provided.
+### Anonymous Authentication
-```java
-IdpConfig phoneConfigWithDefaultNumber = new IdpConfig.PhoneBuilder()
- .setDefaultNumber("ca", "23456789")
- .build();
+Enable anonymous authentication to let users use your app without signing in:
+
+```kotlin
+val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Anonymous()
+ )
+
+ // Enable anonymous user upgrade
+ isAnonymousUpgradeEnabled = true
+}
```
-###### Limiting the list of available countries in the country selector
+### Custom OAuth Provider
-You can limit the countries shown in the country selector list. By default, all countries are shown.
+Support any OAuth provider configured in the Firebase Console:
-You can provide a list of countries to allowlist or blocklist. You can populate these lists with
-ISO (alpha-2) and E164 formatted country codes.
+```kotlin
+val lineProvider = AuthProvider.GenericOAuth(
+ // Required: Provider name
+ providerName = "LINE",
-```java
-List allowedCountries = new ArrayList();
-allowedCountries.add("+1");
-allowedCountries.add("gr");
+ // Required: Provider ID as configured in Firebase Console
+ providerId = "oidc.line",
-IdpConfig phoneConfigWithAllowedCountries = new IdpConfig.PhoneBuilder()
- .setAllowedCountries(allowedCountries)
- .build();
-```
-All countries with the country code +1 will be present in the selector as well as Greece ('gr').
+ // Required: Scopes to request
+ scopes = listOf("profile", "openid", "email"),
-You may want to exclude a few countries from the list and avoid creating a allowlist with
-many countries. You can instead provide a list of countries to blocklist. By doing so, all countries
-excluding the ones you provide will be in the selector.
+ // Required: Custom OAuth parameters
+ customParameters = mapOf("prompt" to "consent"),
-```java
-List blockedCountries = new ArrayList();
-blockedCountries.add("+1");
-blockedCountries.add("gr");
+ // Required: Button label
+ buttonLabel = "Sign in with LINE",
+
+ // Optional: Custom button icon
+ buttonIcon = AuthUIAsset.Resource(R.drawable.ic_line),
+
+ // Optional: Custom button background color
+ buttonColor = Color(0xFF06C755),
+
+ // Optional: Custom button content color
+ contentColor = Color.White
+)
-IdpConfig phoneConfigWithBlockedCountries = new IdpConfig.PhoneBuilder()
- .setBlockedCountries(blockedCountries)
- .build();
+val configuration = authUIConfiguration {
+ providers = listOf(lineProvider)
+}
```
-The country code selector will exclude all countries with a country code of +1 and Greece ('gr').
+## Usage Patterns
+
+### High-Level API (Recommended)
+
+The high-level API provides a complete, opinionated authentication experience with minimal code:
+
+```kotlin
+@Composable
+fun AuthenticationScreen() {
+ val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Email(),
+ AuthProvider.Google(),
+ AuthProvider.Facebook(),
+ AuthProvider.Phone()
+ )
+ tosUrl = "https://example.com/terms"
+ privacyPolicyUrl = "https://example.com/privacy"
+ logo = Icons.Default.Lock
+ }
-Note: You can't provide both a list of countries to allowlist and blocklist. If you do, a runtime
-exception will be thrown.
+ FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result ->
+ val user = result.user
+ val isNewUser = result.additionalUserInfo?.isNewUser ?: false
+
+ if (isNewUser) {
+ // First-time user
+ navigateToOnboarding()
+ } else {
+ // Returning user
+ navigateToHome()
+ }
+ },
+ onSignInFailure = { exception ->
+ when (exception) {
+ is AuthException.NetworkException -> {
+ showSnackbar("No internet connection")
+ }
+ is AuthException.TooManyRequestsException -> {
+ showSnackbar("Too many attempts. Please try again later.")
+ }
+ else -> {
+ showSnackbar("Authentication failed: ${exception.message}")
+ }
+ }
+ },
+ onSignInCancelled = {
+ navigateBack()
+ }
+ )
+}
+```
-This change is purely UI based. We do not restrict users from signing in with their phone number.
-They will simply be unable to choose their country in the selector, but there may be another country
-sharing the same country code (e.g. US and CA are +1).
+### Low-Level API (Advanced)
-#####
+For maximum control, use the `AuthFlowController`:
-### Handling the sign-in response
+```kotlin
+class AuthActivity : ComponentActivity() {
+ private lateinit var controller: AuthFlowController
-#### Response codes
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
-The authentication flow provides several response codes of which the most common are as follows:
-`Activity.RESULT_OK` if a user is signed in, `Activity.RESULT_CANCELED` if the user manually canceled the sign in,
-`ErrorCodes.NO_NETWORK` if sign in failed due to a lack of network connectivity,
-and `ErrorCodes.UNKNOWN_ERROR` for all other errors.
-Typically, the only recourse for most apps if sign in fails is to ask
-the user to sign in again later, or proceed with anonymous sign-in if supported.
+ val authUI = FirebaseAuthUI.getInstance()
+ val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email(), AuthProvider.Google())
+ }
-```java
-private void onSignInResult(FirebaseAuthUIAuthenticationResult result) {
- IdpResponse response = result.getIdpResponse();
+ controller = authUI.createAuthFlow(configuration)
- if (result.getResultCode() == RESULT_OK) {
- // Successfully signed in
- startActivity(SignedInActivity.createIntent(this, response));
- finish();
- } else {
- // Sign in failed
- if (response == null) {
- // User pressed back button
- showSnackbar(R.string.sign_in_cancelled);
- return;
+ lifecycleScope.launch {
+ val state = controller.start()
+ handleAuthState(state)
}
+ }
- if (response.getError().getErrorCode() == ErrorCodes.NO_NETWORK) {
- showSnackbar(R.string.no_internet_connection);
- return;
+ private fun handleAuthState(state: AuthState) {
+ when (state) {
+ is AuthState.Success -> {
+ // Successfully signed in
+ val user = state.result.user
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ }
+ is AuthState.Error -> {
+ // Handle error
+ AlertDialog.Builder(this)
+ .setTitle("Authentication Failed")
+ .setMessage(state.exception.message)
+ .setPositiveButton("OK", null)
+ .show()
+ }
+ is AuthState.RequiresMfa -> {
+ // User needs to complete MFA challenge
+ showMfaChallengeDialog(state.resolver)
+ }
+ is AuthState.RequiresEmailVerification -> {
+ // Email verification needed
+ showEmailVerificationScreen(state.user)
+ }
+ is AuthState.Cancelled -> {
+ // User cancelled authentication
+ finish()
+ }
+ else -> {
+ // Handle other states
+ }
}
+ }
- showSnackbar(R.string.unknown_error);
- Log.e(TAG, "Sign-in error: ", response.getError());
+ override fun onDestroy() {
+ super.onDestroy()
+ controller.dispose()
}
}
```
-Alternatively, you can register a listener for authentication state changes;
-see the Firebase Auth documentation to
-[get the currently signed-in user](https://firebase.google.com/docs/auth/android/manage-users#get_the_currently_signed-in_user)
-and [register an AuthStateListener](https://firebase.google.com/docs/reference/android/com/google/firebase/auth/FirebaseAuth.html#addAuthStateListener(com.google.firebase.auth.FirebaseAuth.AuthStateListener)).
+### Custom UI with Slots
+
+For complete UI control while keeping authentication logic, use content slots:
+
+```kotlin
+@Composable
+fun CustomEmailAuth() {
+ val emailConfig = AuthProvider.Email(
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireDigit
+ )
+ )
+
+ EmailAuthScreen(
+ configuration = emailConfig,
+ onSuccess = { /* ... */ },
+ onError = { /* ... */ },
+ onCancel = { /* ... */ }
+ ) { state ->
+ // Custom UI with full control
+ when (state.mode) {
+ EmailAuthMode.SignIn -> {
+ CustomSignInUI(state)
+ }
+ EmailAuthMode.SignUp -> {
+ CustomSignUpUI(state)
+ }
+ EmailAuthMode.ResetPassword -> {
+ CustomResetPasswordUI(state)
+ }
+ }
+ }
+}
-Note: if you choose to use an `AuthStateListener`, make sure to unregister it before launching
-the FirebaseUI flow and re-register it after the flow returns. FirebaseUI performs auth operations
-internally which may trigger the listener before the flow is complete.
+@Composable
+fun CustomSignInUI(state: EmailAuthContentState) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Welcome Back!",
+ style = MaterialTheme.typography.headlineLarge
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = state.email,
+ onValueChange = state.onEmailChange,
+ label = { Text("Email") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = { Text("Password") },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ if (state.error != null) {
+ Text(
+ text = state.error!!,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
-#### ID tokens
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = state.onSignInClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ } else {
+ Text("Sign In")
+ }
+ }
-To retrieve the ID token that the IDP returned, you can extract an `IdpResponse` from the result
-Intent.
+ TextButton(onClick = state.onGoToResetPassword) {
+ Text("Forgot Password?")
+ }
-```java
-private void onSignInResult(FirebaseAuthUIAuthenticationResult result) {
- if (result.getResultCode() == RESULT_OK) {
- // Successfully signed in
- IdpResponse response = result.getIdpResponse();
- startActivity(new Intent(this, WelcomeBackActivity.class)
- .putExtra("my_token", response.getIdpToken()));
+ TextButton(onClick = state.onGoToSignUp) {
+ Text("Create Account")
+ }
}
}
```
-Twitter also returns an AuthToken Secret which can be accessed with `response.getIdpSecret()`.
+Similarly, create custom phone authentication UI:
+
+```kotlin
+@Composable
+fun CustomPhoneAuth() {
+ val phoneConfig = AuthProvider.Phone(defaultCountryCode = "US")
+
+ PhoneAuthScreen(
+ configuration = phoneConfig,
+ onSuccess = { /* ... */ },
+ onError = { /* ... */ },
+ onCancel = { /* ... */ }
+ ) { state ->
+ when (state.step) {
+ PhoneAuthStep.EnterPhoneNumber -> {
+ CustomPhoneNumberInput(state)
+ }
+ PhoneAuthStep.EnterVerificationCode -> {
+ CustomVerificationCodeInput(state)
+ }
+ }
+ }
+}
+```
-#### User metadata
+## Multi-Factor Authentication
-While `IdpResponse` provides user information about a specific sign-in instance, it is usually
-preferable to find the user name, email, and other metadata directly from the currently signed-in
-`FirebaseUser` instance (`auth.getCurrentUser()`). For example, you could determine if the user
-who just signed in is an existing or new one by comparing the user's creation and last sign-in time:
+### MFA Configuration
-```java
-FirebaseUserMetadata metadata = auth.getCurrentUser().getMetadata();
-if (metadata.getCreationTimestamp() == metadata.getLastSignInTimestamp()) {
- // The user is new, show them a fancy intro screen!
-} else {
- // This is an existing user, show them a welcome back screen.
+Enable and configure Multi-Factor Authentication:
+
+```kotlin
+val mfaConfig = MfaConfiguration(
+ // Allowed MFA factors (default: [Sms, Totp])
+ allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp),
+
+ // Optional: Require MFA enrollment (default: false)
+ requireEnrollment = false,
+
+ // Optional: Enable recovery codes (default: true)
+ enableRecoveryCodes = true
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ isMfaEnabled = true
}
```
-### Sign out
-
-With the integrations provided by AuthUI, signing out a user is a multi-stage process:
+### MFA Enrollment
+
+Prompt users to enroll in MFA after sign-in:
+
+```kotlin
+@Composable
+fun MfaEnrollmentFlow() {
+ val currentUser = FirebaseAuth.getInstance().currentUser
+
+ if (currentUser != null) {
+ val mfaConfig = MfaConfiguration(
+ allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp)
+ )
+
+ MfaEnrollmentScreen(
+ user = currentUser,
+ configuration = mfaConfig,
+ onEnrollmentComplete = {
+ Toast.makeText(context, "MFA enrolled successfully!", Toast.LENGTH_SHORT).show()
+ navigateToHome()
+ },
+ onSkip = {
+ navigateToHome()
+ }
+ )
+ }
+}
+```
-1. The user must be signed out of the FirebaseAuth instance.
-1. Credential Manager must be instructed to clear the current user credential state from
- all credential providers.
-1. If the current user signed in using either Google or Facebook, the user must
- also be signed out using the associated API for that authentication method.
- This typically ensures that the user will not be automatically signed-in
- using the current account when using that authentication method again from
- the authentication method picker, which would also prevent the user from
- switching between accounts on the same provider.
+Or with custom UI:
+
+```kotlin
+MfaEnrollmentScreen(
+ user = currentUser,
+ configuration = mfaConfig,
+ onEnrollmentComplete = { /* ... */ },
+ onSkip = { /* ... */ }
+) { state ->
+ when (state.step) {
+ MfaEnrollmentStep.SelectFactor -> {
+ CustomFactorSelectionUI(state)
+ }
+ MfaEnrollmentStep.ConfigureSms -> {
+ CustomSmsConfigurationUI(state)
+ }
+ MfaEnrollmentStep.ConfigureTotp -> {
+ CustomTotpConfigurationUI(state)
+ }
+ MfaEnrollmentStep.VerifyFactor -> {
+ CustomVerificationUI(state)
+ }
+ MfaEnrollmentStep.ShowRecoveryCodes -> {
+ CustomRecoveryCodesUI(state)
+ }
+ }
+}
+```
-In order to make this process easier, AuthUI provides a simple `signOut` method
-to encapsulate this behavior. The method returns a `Task` which is marked
-completed once all necessary sign-out operations are completed:
+### MFA Challenge
+
+Handle MFA challenges during sign-in. The challenge is automatically detected:
+
+```kotlin
+FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result ->
+ navigateToHome()
+ },
+ onSignInFailure = { exception ->
+ // MFA challenges are handled automatically by FirebaseAuthScreen
+ // But you can also handle them manually:
+ if (exception is AuthException.MfaRequiredException) {
+ showMfaChallengeScreen(exception.resolver)
+ }
+ }
+)
+```
-```java
-public void onClick(View v) {
-if (v.getId() == R.id.sign_out) {
- AuthUI.getInstance()
- .signOut(this)
- .addOnCompleteListener(new OnCompleteListener() {
- public void onComplete(@NonNull Task task) {
- // user is now signed out
- startActivity(new Intent(MyActivity.this, SignInActivity.class));
- finish();
+Or handle manually:
+
+```kotlin
+@Composable
+fun ManualMfaChallenge(resolver: MultiFactorResolver) {
+ MfaChallengeScreen(
+ resolver = resolver,
+ onChallengeComplete = { assertion ->
+ // Complete sign-in with the assertion
+ lifecycleScope.launch {
+ try {
+ val result = resolver.resolveSignIn(assertion)
+ navigateToHome()
+ } catch (e: Exception) {
+ showError(e)
+ }
}
- });
+ },
+ onCancel = {
+ navigateBack()
+ }
+ )
+}
+```
+
+## Theming & Customization
+
+### Material Theme Integration
+
+FirebaseUI automatically inherits your app's Material Theme:
+
+```kotlin
+@Composable
+fun App() {
+ MyAppTheme { // Your existing Material3 theme
+ val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ theme = AuthUITheme.fromMaterialTheme() // Inherits MyAppTheme
+ }
+
+ FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { /* ... */ }
+ )
}
}
```
-### Deleting accounts
+### Custom Theme
+
+Create a completely custom theme:
+
+```kotlin
+val customTheme = AuthUITheme(
+ colorScheme = darkColorScheme(
+ primary = Color(0xFF6200EE),
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFF3700B3),
+ secondary = Color(0xFF03DAC6)
+ ),
+ typography = Typography(
+ displayLarge = TextStyle(fontSize = 57.sp, fontWeight = FontWeight.Bold),
+ bodyLarge = TextStyle(fontSize = 16.sp)
+ ),
+ shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(8.dp),
+ large = RoundedCornerShape(16.dp)
+ )
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ theme = customTheme
+}
+```
-With the integrations provided by FirebaseUI Auth, deleting a user is a multi-stage process:
+### Provider Button Styling
+
+Customize individual provider button styling:
+
+```kotlin
+val customProviderStyles = mapOf(
+ "google.com" to AuthUITheme.ProviderStyle(
+ backgroundColor = Color.White,
+ contentColor = Color(0xFF757575),
+ iconTint = null, // Use original colors
+ shape = RoundedCornerShape(8.dp),
+ elevation = 4.dp
+ ),
+ "facebook.com" to AuthUITheme.ProviderStyle(
+ backgroundColor = Color(0xFF1877F2),
+ contentColor = Color.White,
+ shape = RoundedCornerShape(12.dp),
+ elevation = 0.dp
+ )
+)
+
+val customTheme = AuthUITheme.Default.copy(
+ providerStyles = customProviderStyles
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Google(), AuthProvider.Facebook())
+ theme = customTheme
+}
+```
-1. The user must be deleted from Firebase Auth.
-1. Credential Manager must be told to delete any existing Credentials for the user, so
- that they are not automatically prompted to sign in with a saved credential in the future.
+## Advanced Features
-This process is encapsulated by the `AuthUI.delete()` method, which returns a `Task` representing
-the entire operation:
+### Anonymous User Upgrade
-```java
-AuthUI.getInstance()
- .delete(this)
- .addOnCompleteListener(new OnCompleteListener() {
- @Override
- public void onComplete(@NonNull Task task) {
- if (task.isSuccessful()) {
- // Deletion succeeded
- } else {
- // Deletion failed
+Seamlessly upgrade anonymous users to permanent accounts:
+
+```kotlin
+// 1. Configure anonymous authentication with upgrade enabled
+val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Anonymous(),
+ AuthProvider.Email(),
+ AuthProvider.Google()
+ )
+ isAnonymousUpgradeEnabled = true
+}
+
+// 2. When user wants to create a permanent account, show auth UI
+// The library automatically upgrades the anonymous account if one exists
+FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result ->
+ // Anonymous account has been upgraded (if user was anonymous)!
+ Toast.makeText(this, "Account created!", Toast.LENGTH_SHORT).show()
+ }
+)
+```
+
+### Email Link Sign-In
+
+Enable passwordless email link authentication:
+
+```kotlin
+val emailProvider = AuthProvider.Email(
+ isEmailLinkSignInEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ url = "https://example.com/auth"
+ handleCodeInApp = true
+ setAndroidPackageName(packageName, true, "12")
+ },
+ passwordValidationRules = emptyList()
+)
+
+val configuration = authUIConfiguration {
+ providers = listOf(emailProvider)
+}
+```
+
+**High-Level API** - Direct `FirebaseAuthScreen` usage:
+
+```kotlin
+// In your Activity that handles the deep link:
+override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val emailLink = if (authUI.canHandleIntent(intent)) {
+ intent.data?.toString()
+ } else {
+ null
+ }
+
+ if (emailLink != null) {
+ setContent {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ emailLink = emailLink,
+ onSignInSuccess = { result ->
+ // Email link sign-in successful
+ },
+ onSignInFailure = { exception ->
+ // Handle error
+ },
+ onSignInCancelled = {
+ finish()
}
- }
- });
+ )
+ }
+ }
+}
```
-### Upgrading anonymous users
+**Low-Level API** - Using `AuthFlowController`:
-#### Enabling anonymous user upgrade
+```kotlin
+import com.firebase.ui.auth.util.EmailLinkConstants
-When an anonymous user signs in or signs up with a permanent account, you want
-to be sure that the user can continue with what they were doing before signing up.
-For example, an anonymous user might have items in their shopping cart.
-At check-out, you prompt the user to sign in or sign up. After the user is
-signed in, the user's shopping cart should contain any items the user added
-while signed in anonymously.
+// In your Activity that handles the deep link:
+override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
-To support this behavior, FirebaseUI makes it easy to "upgrade" an anonymous
-account to a permanent account. To do so, simply call `enableAnonymousUsersAutoUpgrade()`
-when you configure the sign-in UI (this option is disabled by default).
+ val authUI = FirebaseAuthUI.getInstance()
+ val emailLink = if (authUI.canHandleIntent(intent)) {
+ intent.data?.toString()
+ } else {
+ null
+ }
-For example:
-```java
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .enableAnonymousUsersAutoUpgrade()
- ...
- .build();
+ if (emailLink != null) {
+ val controller = authUI.createAuthFlow(configuration)
+ val intent = controller.createIntent(this).apply {
+ putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, emailLink)
+ }
+ authLauncher.launch(intent)
+ }
+}
+
+// Handle result
+private val authLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ // Email link sign-in successful
+ }
+ Activity.RESULT_CANCELED -> {
+ // Handle error or cancellation
+ }
+ }
+}
```
-With this enabled, FirebaseUI will link the credential on sign-in with the anonymous account
-using Firebase Auth's `linkWithCredential` method:
-```java
-FirebaseAuth.getInstance().getCurrentUser().linkWithCredential(permanentCredential);
+Add the intent filter to your `AndroidManifest.xml`:
+
+```xml
+
+
+
+
+
+
```
-#### Handling anonymous user upgrade merge conflicts
+### Password Validation Rules
+
+Enforce custom password requirements:
+
+```kotlin
+val emailProvider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ minimumPasswordLength = 10,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(10),
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireDigit,
+ PasswordRule.RequireSpecialCharacter,
+ PasswordRule.Custom(
+ regex = Regex("^(?!.*password).*$"),
+ errorMessage = "Password cannot contain the word 'password'"
+ )
+ )
+)
+```
-There is an issue when an anonymous user tries to upgrade to an existing Firebase user.
+### Credential Manager Integration
-For example, a user may have previously signed up with a Google credential on a different device.
-If they are signed in anonymously and they attempt to upgrade with the existing Google account,
-a `FirebaseAuthUserCollisionException` will be thrown by Firebase Auth as an existing user
-cannot be linked to another existing user. No two users can share the same credential. In this case,
-we need to merge the data from both users before we can upgrade the anonymous user.
+FirebaseUI automatically integrates with Android's Credential Manager API to save and retrieve credentials. This enables:
-The process of storing the anonymous users data, signing in with the credential, and copying the
-data over to the existing account is left to the developer.
+- **Automatic sign-in** for returning users
+- **One-tap sign-in** across apps
+- **Secure credential storage**
-When linking is unsuccessful due to user collision, an error with code
-`ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT` will be returned to the callback of your `ActivityResultLauncher`. A valid
-non-anonymous credential can be obtained from the `IdpResponse` via `getCredentialForLinking()`.
+Credential Manager is enabled by default. To disable:
-**Example:**
-```java
-private void onSignInResult(FirebaseAuthUIAuthenticationResult result) {
- IdpResponse response = result.getIdpResponse();
+```kotlin
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ isCredentialManagerEnabled = false
+}
+```
- if (result.getResultCode() == RESULT_OK) {
- // Successfully signed in
- // ...
- } else {
- // Sign in failed
- if (response.getError().getErrorCode() == ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT) {
- // Store relevant anonymous user data
- ...
- // Get the non-anonymous credential from the response
- AuthCredential nonAnonymousCredential = response.getCredentialForLinking();
- // Sign in with credential
- FirebaseAuth.getInstance().signInWithCredential(nonAnonymousCredential)
- .addOnSuccessListener(new OnSuccessListener() {
- @Override
- public void onSuccess(AuthResult result) {
- // Copy over anonymous user data to signed in user
- ...
+### Sign Out & Account Deletion
+
+**Sign Out:**
+
+```kotlin
+@Composable
+fun SettingsScreen() {
+ val context = LocalContext.current
+ val authUI = remember { FirebaseAuthUI.getInstance() }
+
+ Button(
+ onClick = {
+ lifecycleScope.launch {
+ authUI.signOut(context)
+ // User is signed out, navigate to auth screen
+ navigateToAuth()
+ }
+ }
+ ) {
+ Text("Sign Out")
+ }
+}
+```
+
+**Delete Account:**
+
+```kotlin
+Button(
+ onClick = {
+ lifecycleScope.launch {
+ try {
+ authUI.delete(context)
+ // Account deleted successfully
+ navigateToAuth()
+ } catch (e: Exception) {
+ when (e) {
+ is FirebaseAuthRecentLoginRequiredException -> {
+ // User needs to reauthenticate
+ showReauthenticationDialog()
+ }
+ else -> {
+ showError("Failed to delete account: ${e.message}")
}
- });
+ }
+ }
}
}
- updateUI();
+) {
+ Text("Delete Account")
}
```
-### Custom Layout
+## Localization
-The first screen shown in most cases is the auth method picker screen, where the user selects
-from a list of authentication methods. While customization in other screens of FirebaseUI is
-limited to themes, this screen can be fully customized with your own XML layout.
+FirebaseUI includes default English strings. To add custom localization:
-To customize the auth method picker screen, build an `AuthMethodPickerLayout` object and pass
-it to the `SignInIntentBuilder` before launching the AuthUI flow:
+```kotlin
+class SpanishStringProvider(context: Context) : AuthUIStringProvider {
+ override fun signInWithEmail() = "Iniciar sesión con correo"
+ override fun signInWithGoogle() = "Iniciar sesión con Google"
+ override fun signInWithFacebook() = "Iniciar sesión con Facebook"
+ override fun invalidEmail() = "Correo inválido"
+ override fun weakPassword() = "Contraseña débil"
+ // ... implement all other required methods
+}
-```java
-// You must provide a custom layout XML resource and configure at least one
-// provider button ID. It's important that that you set the button ID for every provider
-// that you have enabled.
-AuthMethodPickerLayout customLayout = new AuthMethodPickerLayout
- .Builder(R.layout.your_custom_layout_xml)
- .setGoogleButtonId(R.id.bar)
- .setEmailButtonId(R.id.foo)
- // ...
- .setTosAndPrivacyPolicyId(R.id.baz)
- .build();
-
-Intent signInIntent =
- AuthUI.getInstance(this).createSignInIntentBuilder()
- // ...
- .setAuthMethodPickerLayout(customLayout)
- .build();
+val configuration = authUIConfiguration {
+ providers = listOf(AuthProvider.Email())
+ stringProvider = SpanishStringProvider(context)
+ locale = Locale("es", "ES")
+}
```
-### Strings
-
-Ensure an `app_name` resource is defined your `strings.xml` file like so:
+Or override individual strings in your `strings.xml`:
```xml
- My App
-
+
+ Sign in with Google
+ Sign in with Email
+ Invalid email address
+
```
-If you wish to change other strings in the UI, the existing strings can be overridden
-by name in your application. See the module's [strings.xml](src/main/res/values/strings.xml) file
-and simply redefine a string to change it:
+## Error Handling
-```xml
-
-
- Creating your shiny new account...
-
+FirebaseUI provides a comprehensive exception hierarchy:
+
+```kotlin
+FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInFailure = { exception ->
+ when (exception) {
+ is AuthException.NetworkException -> {
+ showSnackbar("No internet connection. Please check your network.")
+ }
+ is AuthException.InvalidCredentialsException -> {
+ showSnackbar("Invalid email or password.")
+ }
+ is AuthException.UserNotFoundException -> {
+ showSnackbar("No account found with this email.")
+ }
+ is AuthException.WeakPasswordException -> {
+ showSnackbar("Password is too weak. Please use a stronger password.")
+ }
+ is AuthException.EmailAlreadyInUseException -> {
+ showSnackbar("An account already exists with this email.")
+ }
+ is AuthException.TooManyRequestsException -> {
+ showSnackbar("Too many attempts. Please try again later.")
+ }
+ is AuthException.MfaRequiredException -> {
+ // Handled automatically by FirebaseAuthScreen
+ // or show custom MFA challenge
+ }
+ is AuthException.AccountLinkingRequiredException -> {
+ // Account needs to be linked
+ showAccountLinkingDialog(exception)
+ }
+ is AuthException.AuthCancelledException -> {
+ // User cancelled the flow
+ navigateBack()
+ }
+ is AuthException.UnknownException -> {
+ showSnackbar("An unexpected error occurred: ${exception.message}")
+ Log.e(TAG, "Auth error", exception)
+ }
+ }
+ }
+)
+```
+
+Use the `ErrorRecoveryDialog` for automatic error handling:
+
+```kotlin
+var errorState by remember { mutableStateOf(null) }
+
+errorState?.let { error ->
+ ErrorRecoveryDialog(
+ error = error,
+ onRetry = {
+ // Retry the authentication
+ errorState = null
+ retryAuthentication()
+ },
+ onDismiss = {
+ errorState = null
+ },
+ onRecover = { exception ->
+ // Custom recovery logic for specific errors
+ when (exception) {
+ is AuthException.AccountLinkingRequiredException -> {
+ linkAccounts(exception)
+ }
+ }
+ }
+ )
+}
```
-**Note:** String resource names aren't considered part of the public API and might
-therefore change and break your app between library updates. We recommend looking
-at a diff of the `strings.xml` file before updating FirebaseUI.
+## Migration Guide
+
+### From FirebaseUI Auth 9.x (View-based)
-## OAuth scope customization
+The new Compose library has a completely different architecture. Here's how to migrate:
-### Google
-By default, FirebaseUI requests the `email` and `profile` scopes when using Google Sign-In. If you
-would like to request additional scopes from the user, call `setScopes` on the
-`AuthUI.IdpConfig.GoogleBuilder` when initializing FirebaseUI.
+**Old (9.x - View/Activity based):**
```java
-// For a list of all scopes, see:
-// https://developers.google.com/identity/protocols/googlescopes
-AuthUI.IdpConfig googleIdp = new AuthUI.IdpConfig.GoogleBuilder()
- .setScopes(Arrays.asList(Scopes.GAMES))
- .build();
+// Old approach with startActivityForResult
+Intent signInIntent = AuthUI.getInstance()
+ .createSignInIntentBuilder()
+ .setAvailableProviders(Arrays.asList(
+ new AuthUI.IdpConfig.EmailBuilder().build(),
+ new AuthUI.IdpConfig.GoogleBuilder().build()
+ ))
+ .setTheme(R.style.AppTheme)
+ .build();
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setAvailableProviders(Arrays.asList(googleIdp, ...))
- .build();
+signInLauncher.launch(signInIntent);
```
-### Facebook
+**New (10.x - Compose based):**
-By default, FirebaseUI requests the `email` and `public_profile` permissions when initiating
-Facebook Login. If you would like to request additional permissions from the user, call
-`setPermissions` on the `AuthUI.IdpConfig.FacebookBuilder` when initializing FirebaseUI.
+```kotlin
+// New approach with Composable
+val configuration = authUIConfiguration {
+ providers = listOf(
+ AuthProvider.Email(),
+ AuthProvider.Google()
+ )
+ theme = AuthUITheme.fromMaterialTheme()
+}
-```java
-// For a list of permissions see:
-// https://developers.facebook.com/docs/facebook-login/permissions
+FirebaseAuthScreen(
+ configuration = configuration,
+ onSignInSuccess = { result -> /* ... */ },
+ onSignInFailure = { exception -> /* ... */ },
+ onSignInCancelled = { /* ... */ }
+)
+```
-AuthUI.IdpConfig facebookIdp = new AuthUI.IdpConfig.FacebookBuilder()
- .setPermissions(Arrays.asList("user_friends"))
- .build();
+**Key Changes:**
-Intent signInIntent =
- AuthUI.getInstance()
- .createSignInIntentBuilder()
- .setAvailableProviders(Arrays.asList(facebookIdp, ...))
- .build();
-```
+1. **Pure Compose** - No more Activities or Intents, everything is Composable
+2. **Configuration DSL** - Use `authUIConfiguration {}` instead of `createSignInIntentBuilder()`
+3. **Provider Builders** - `AuthProvider.Email()` instead of `IdpConfig.EmailBuilder().build()`
+4. **Callbacks** - Direct callback parameters instead of `ActivityResultLauncher`
+5. **Theming** - `AuthUITheme` instead of `R.style` theme resources
+6. **State Management** - Reactive `Flow` instead of `AuthStateListener`
+
+**Migration Checklist:**
+
+- [ ] Update dependency to `firebase-ui-auth:10.0.0`
+- [ ] Convert Activities to Composables
+- [ ] Replace Intent-based flow with `FirebaseAuthScreen`
+- [ ] Update configuration from builder pattern to DSL
+- [ ] Replace theme resources with `AuthUITheme`
+- [ ] Update error handling from result codes to `AuthException`
+- [ ] Remove `ActivityResultLauncher` and use direct callbacks
+- [ ] Update sign-out/delete to use suspend functions
+
+For a complete migration example, see the [migration guide](MIGRATION.md).
+
+---
+
+## Contributing
+
+Contributions are welcome! Please read our [contribution guidelines](../CONTRIBUTING.md) before submitting PRs.
+
+## License
+
+FirebaseUI Auth is available under the [Apache 2.0 license](../LICENSE).
-### Twitter
+## Support
-Twitter permissions can only be configured through [Twitter's developer console](https://apps.twitter.com/).
+- [Firebase Documentation](https://firebase.google.com/docs/auth)
+- [GitHub Issues](https://github.com/firebase/FirebaseUI-Android/issues)
+- [Stack Overflow](https://stackoverflow.com/questions/tagged/firebaseui)
diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts
index 66df263a8..14a0a7f98 100644
--- a/auth/build.gradle.kts
+++ b/auth/build.gradle.kts
@@ -1,9 +1,8 @@
-import com.android.build.gradle.internal.dsl.TestOptions
-
plugins {
id("com.android.library")
id("com.vanniktech.maven.publish")
id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose") version Config.kotlinVersion
}
android {
@@ -12,8 +11,9 @@ android {
defaultConfig {
minSdk = Config.SdkVersions.min
- targetSdk =Config.SdkVersions.target
+ targetSdk = Config.SdkVersions.target
+ buildConfigField("String", "LIBRARY_NAME", "\"firebase-ui-android\"")
buildConfigField("String", "VERSION_NAME", "\"${Config.version}\"")
resourcePrefix("fui_")
@@ -26,8 +26,8 @@ android {
consumerProguardFiles("auth-proguard.pro")
}
}
-
- compileOptions {
+
+ compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -67,30 +67,52 @@ android {
kotlinOptions {
jvmTarget = "17"
}
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
}
dependencies {
- implementation(Config.Libs.Androidx.materialDesign)
+ implementation(platform(Config.Libs.Androidx.Compose.bom))
+ implementation(Config.Libs.Androidx.Compose.ui)
+ implementation(Config.Libs.Androidx.Compose.uiGraphics)
+ implementation(Config.Libs.Androidx.Compose.material3)
+ implementation(Config.Libs.Androidx.Compose.foundation)
+ implementation(Config.Libs.Androidx.Compose.tooling)
+ implementation(Config.Libs.Androidx.Compose.toolingPreview)
+ implementation(Config.Libs.Androidx.Compose.activityCompose)
implementation(Config.Libs.Androidx.activity)
+ implementation(Config.Libs.Androidx.materialDesign)
+ implementation(Config.Libs.Androidx.Compose.materialIconsExtended)
+ implementation(Config.Libs.Androidx.datastorePreferences)
// The new activity result APIs force us to include Fragment 1.3.0
// See https://issuetracker.google.com/issues/152554847
implementation(Config.Libs.Androidx.fragment)
implementation(Config.Libs.Androidx.customTabs)
implementation(Config.Libs.Androidx.constraint)
- implementation("androidx.credentials:credentials:1.3.0")
- implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
+
+ // Google Authentication
+ implementation(Config.Libs.Androidx.credentials)
+ implementation(Config.Libs.Androidx.credentialsPlayServices)
+ implementation(Config.Libs.Misc.googleid)
+ implementation(Config.Libs.PlayServices.auth)
+ //api(Config.Libs.PlayServices.auth)
implementation(Config.Libs.Androidx.lifecycleExtensions)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
- implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
+ implementation("androidx.navigation:navigation-compose:2.8.3")
+ implementation("com.google.zxing:core:3.5.3")
annotationProcessor(Config.Libs.Androidx.lifecycleCompiler)
implementation(platform(Config.Libs.Firebase.bom))
api(Config.Libs.Firebase.auth)
- api(Config.Libs.PlayServices.auth)
+
+ // Phone number validation
+ implementation(Config.Libs.Misc.libphonenumber)
compileOnly(Config.Libs.Provider.facebook)
implementation(Config.Libs.Androidx.legacySupportv4) // Needed to override deps
@@ -98,10 +120,27 @@ dependencies {
testImplementation(Config.Libs.Test.junit)
testImplementation(Config.Libs.Test.truth)
- testImplementation(Config.Libs.Test.mockito)
testImplementation(Config.Libs.Test.core)
testImplementation(Config.Libs.Test.robolectric)
+ testImplementation(Config.Libs.Test.kotlinReflect)
testImplementation(Config.Libs.Provider.facebook)
+ testImplementation(Config.Libs.Test.mockitoCore)
+ testImplementation(Config.Libs.Test.mockitoInline)
+ testImplementation(Config.Libs.Test.mockitoKotlin)
+ testImplementation(Config.Libs.Androidx.credentials)
+ testImplementation(Config.Libs.Test.composeUiTestJunit4)
debugImplementation(project(":internal:lintchecks"))
}
+
+val mockitoAgent by configurations.creating
+
+dependencies {
+ mockitoAgent(Config.Libs.Test.mockitoCore) {
+ isTransitive = false
+ }
+}
+
+tasks.withType().configureEach {
+ jvmArgs("-javaagent:${mockitoAgent.asPath}")
+}
diff --git a/auth/demo.gif b/auth/demo.gif
index 9d844015c..4ef178890 100644
Binary files a/auth/demo.gif and b/auth/demo.gif differ
diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml
index bb1a19204..29f9d060e 100644
--- a/auth/src/main/AndroidManifest.xml
+++ b/auth/src/main/AndroidManifest.xml
@@ -1,14 +1,11 @@
-
+
-
-
+
@@ -19,6 +16,12 @@
+
+
+
+
@@ -27,76 +30,13 @@
android:name="com.facebook.sdk.ApplicationId"
android:value="@string/facebook_application_id" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
new file mode 100644
index 000000000..63af11c73
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthException.kt
@@ -0,0 +1,459 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth
+
+import com.firebase.ui.auth.AuthException.Companion.from
+import com.google.firebase.FirebaseException
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.FirebaseAuthException
+import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
+import com.google.firebase.auth.FirebaseAuthInvalidUserException
+import com.google.firebase.auth.FirebaseAuthMultiFactorException
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.FirebaseAuthWeakPasswordException
+
+/**
+ * Abstract base class representing all possible authentication exceptions in Firebase Auth UI.
+ *
+ * This class provides a unified exception hierarchy for authentication operations, allowing
+ * for consistent error handling across the entire Auth UI system.
+ *
+ * Use the companion object [from] method to create specific exception instances from
+ * Firebase authentication exceptions.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * try {
+ * // Perform authentication operation
+ * } catch (firebaseException: Exception) {
+ * val authException = AuthException.from(firebaseException)
+ * when (authException) {
+ * is AuthException.NetworkException -> {
+ * // Handle network error
+ * }
+ * is AuthException.InvalidCredentialsException -> {
+ * // Handle invalid credentials
+ * }
+ * // ... handle other exception types
+ * }
+ * }
+ * ```
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ *
+ * @since 10.0.0
+ */
+abstract class AuthException(
+ message: String,
+ cause: Throwable? = null
+) : Exception(message, cause) {
+
+ /**
+ * A network error occurred during the authentication operation.
+ *
+ * This exception is thrown when there are connectivity issues, timeouts,
+ * or other network-related problems.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class NetworkException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * The provided credentials are not valid.
+ *
+ * This exception is thrown when the user provides incorrect login information,
+ * such as wrong email/password combinations or malformed credentials.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class InvalidCredentialsException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * The user account does not exist.
+ *
+ * This exception is thrown when attempting to sign in with credentials
+ * for a user that doesn't exist in the Firebase Auth system.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class UserNotFoundException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * The password provided is not strong enough.
+ *
+ * This exception is thrown when creating an account or updating a password
+ * with a password that doesn't meet the security requirements.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ * @property reason The specific reason why the password is considered weak
+ */
+ class WeakPasswordException(
+ message: String,
+ cause: Throwable? = null,
+ val reason: String? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * An account with the given email already exists.
+ *
+ * This exception is thrown when attempting to create a new account with
+ * an email address that is already registered.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ * @property email The email address that already exists
+ */
+ class EmailAlreadyInUseException(
+ message: String,
+ cause: Throwable? = null,
+ val email: String? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * Too many requests have been made to the server.
+ *
+ * This exception is thrown when the client has made too many requests
+ * in a short period and needs to wait before making additional requests.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class TooManyRequestsException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * Multi-Factor Authentication is required to proceed.
+ *
+ * This exception is thrown when a user has MFA enabled and needs to
+ * complete additional authentication steps.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class MfaRequiredException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * Account linking is required to complete sign-in.
+ *
+ * This exception is thrown when a user tries to sign in with a provider
+ * that needs to be linked to an existing account. For example, when a user
+ * tries to sign in with Facebook but an account already exists with that
+ * email using a different provider (like email/password).
+ *
+ * @property message The detailed error message
+ * @property email The email address that already has an account (optional)
+ * @property credential The credential that should be linked after signing in (optional)
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class AccountLinkingRequiredException(
+ message: String,
+ val email: String? = null,
+ val credential: AuthCredential? = null,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * Authentication was cancelled by the user.
+ *
+ * This exception is thrown when the user cancels an authentication flow,
+ * such as dismissing a sign-in dialog or backing out of the process.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class AuthCancelledException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * An unknown or unhandled error occurred.
+ *
+ * This exception is thrown for errors that don't match any of the specific
+ * exception types or for unexpected system errors.
+ *
+ * @property message The detailed error message
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class UnknownException(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthException(message, cause)
+
+ /**
+ * The email link used for sign-in is invalid or malformed.
+ *
+ * This exception is thrown when the link is not a valid Firebase email link,
+ * has incorrect format, or is missing required parameters.
+ *
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class InvalidEmailLinkException(
+ cause: Throwable? = null
+ ) : AuthException("You are are attempting to sign in with an invalid email link", cause)
+
+ /**
+ * The email link is being used on a different device than where it was requested.
+ *
+ * This exception is thrown when `forceSameDevice = true` and the user opens
+ * the link on a different device than the one used to request it.
+ *
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailLinkWrongDeviceException(
+ cause: Throwable? = null
+ ) : AuthException("You must open the email link on the same device.", cause)
+
+ /**
+ * Cross-device account linking is required to complete email link sign-in.
+ *
+ * This exception is thrown when the email link matches an existing account with
+ * a social provider (Google/Facebook), and the user needs to sign in with that
+ * provider to link accounts.
+ *
+ * @property providerName The name of the social provider that needs to be linked
+ * @property emailLink The email link being processed
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailLinkCrossDeviceLinkingException(
+ val providerName: String? = null,
+ val emailLink: String? = null,
+ cause: Throwable? = null
+ ) : AuthException("You must determine if you want to continue linking or " +
+ "complete the sign in", cause)
+
+ /**
+ * User needs to provide their email address to complete email link sign-in.
+ *
+ * This exception is thrown when the email link is opened on a different device
+ * and the email address cannot be determined from stored session data.
+ *
+ * @property emailLink The email link to be used after email is provided
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailLinkPromptForEmailException(
+ cause: Throwable? = null,
+ val emailLink: String? = null,
+ ) : AuthException("Please enter your email to continue signing in", cause)
+
+ /**
+ * Email link sign-in attempted with a different anonymous user than expected.
+ *
+ * This exception is thrown when an email link for anonymous account upgrade is
+ * opened on a device with a different anonymous user session.
+ *
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailLinkDifferentAnonymousUserException(
+ cause: Throwable? = null
+ ) : AuthException("The session associated with this sign-in request has either " +
+ "expired or was cleared", cause)
+
+ /**
+ * The email address provided does not match the email link.
+ *
+ * This exception is thrown when the user enters an email address that doesn't
+ * match the email to which the sign-in link was sent.
+ *
+ * @property cause The underlying [Throwable] that caused this exception
+ */
+ class EmailMismatchException(
+ cause: Throwable? = null
+ ) : AuthException("You are are attempting to sign in a different email " +
+ "than previously provided", cause)
+
+ companion object {
+ /**
+ * Creates an appropriate [AuthException] instance from a Firebase authentication exception.
+ *
+ * This method maps known Firebase exception types to their corresponding [AuthException]
+ * subtypes, providing a consistent exception hierarchy for error handling.
+ *
+ * **Mapping:**
+ * - [FirebaseException] → [NetworkException] (for network-related errors)
+ * - [FirebaseAuthInvalidCredentialsException] → [InvalidCredentialsException]
+ * - [FirebaseAuthInvalidUserException] → [UserNotFoundException]
+ * - [FirebaseAuthWeakPasswordException] → [WeakPasswordException]
+ * - [FirebaseAuthUserCollisionException] → [EmailAlreadyInUseException]
+ * - [FirebaseAuthException] with ERROR_TOO_MANY_REQUESTS → [TooManyRequestsException]
+ * - [FirebaseAuthMultiFactorException] → [MfaRequiredException]
+ * - Other exceptions → [UnknownException]
+ *
+ * **Example:**
+ * ```kotlin
+ * try {
+ * // Firebase auth operation
+ * } catch (firebaseException: Exception) {
+ * val authException = AuthException.from(firebaseException)
+ * handleAuthError(authException)
+ * }
+ * ```
+ *
+ * @param firebaseException The Firebase exception to convert
+ * @return An appropriate [AuthException] subtype
+ */
+ @JvmStatic
+ fun from(firebaseException: Exception): AuthException {
+ return when (firebaseException) {
+ // If already an AuthException, return it directly
+ is AuthException -> firebaseException
+
+ // Handle specific Firebase Auth exceptions first (before general FirebaseException)
+ is FirebaseAuthInvalidCredentialsException -> {
+ InvalidCredentialsException(
+ message = firebaseException.message ?: "Invalid credentials provided",
+ cause = firebaseException
+ )
+ }
+
+ is FirebaseAuthInvalidUserException -> {
+ when (firebaseException.errorCode) {
+ "ERROR_USER_NOT_FOUND" -> UserNotFoundException(
+ message = firebaseException.message ?: "User not found",
+ cause = firebaseException
+ )
+
+ "ERROR_USER_DISABLED" -> InvalidCredentialsException(
+ message = firebaseException.message ?: "User account has been disabled",
+ cause = firebaseException
+ )
+
+ else -> UserNotFoundException(
+ message = firebaseException.message ?: "User account error",
+ cause = firebaseException
+ )
+ }
+ }
+
+ is FirebaseAuthWeakPasswordException -> {
+ WeakPasswordException(
+ message = firebaseException.message ?: "Password is too weak",
+ cause = firebaseException,
+ reason = firebaseException.reason
+ )
+ }
+
+ is FirebaseAuthUserCollisionException -> {
+ when (firebaseException.errorCode) {
+ "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException(
+ message = firebaseException.message
+ ?: "Email address is already in use",
+ cause = firebaseException,
+ email = firebaseException.email
+ )
+
+ "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException(
+ message = firebaseException.message
+ ?: "Account already exists with different credentials",
+ cause = firebaseException
+ )
+
+ "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException(
+ message = firebaseException.message
+ ?: "Credential is already associated with a different user account",
+ cause = firebaseException
+ )
+
+ else -> AccountLinkingRequiredException(
+ message = firebaseException.message ?: "Account collision error",
+ cause = firebaseException
+ )
+ }
+ }
+
+ is FirebaseAuthMultiFactorException -> {
+ MfaRequiredException(
+ message = firebaseException.message
+ ?: "Multi-factor authentication required",
+ cause = firebaseException
+ )
+ }
+
+ is FirebaseAuthRecentLoginRequiredException -> {
+ InvalidCredentialsException(
+ message = firebaseException.message
+ ?: "Recent login required for this operation",
+ cause = firebaseException
+ )
+ }
+
+ is FirebaseAuthException -> {
+ // Handle FirebaseAuthException and check for specific error codes
+ when (firebaseException.errorCode) {
+ "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException(
+ message = firebaseException.message
+ ?: "Too many requests. Please try again later",
+ cause = firebaseException
+ )
+
+ else -> UnknownException(
+ message = firebaseException.message
+ ?: "An unknown authentication error occurred",
+ cause = firebaseException
+ )
+ }
+ }
+
+ is FirebaseException -> {
+ // Handle general Firebase exceptions, which include network errors
+ NetworkException(
+ message = firebaseException.message ?: "Network error occurred",
+ cause = firebaseException
+ )
+ }
+
+ else -> {
+ // Check for common cancellation patterns
+ if (firebaseException.message?.contains(
+ "cancelled",
+ ignoreCase = true
+ ) == true ||
+ firebaseException.message?.contains("canceled", ignoreCase = true) == true
+ ) {
+ AuthCancelledException(
+ message = firebaseException.message ?: "Authentication was cancelled",
+ cause = firebaseException
+ )
+ } else {
+ UnknownException(
+ message = firebaseException.message ?: "An unknown error occurred",
+ cause = firebaseException
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
new file mode 100644
index 000000000..44cdf45aa
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthFlowController.kt
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.ActivityResultLauncher
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Controller for managing the Firebase authentication flow lifecycle.
+ *
+ * This controller provides a lifecycle-safe way to start, monitor, and cancel
+ * the authentication flow. It handles coroutine lifecycle, state listeners,
+ * and resource cleanup automatically.
+ *
+ * **Usage Pattern:**
+ * ```kotlin
+ * class MyActivity : ComponentActivity() {
+ * private lateinit var authController: AuthFlowController
+ *
+ * private val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * // Auth flow completed
+ * }
+ *
+ * override fun onCreate(savedInstanceState: Bundle?) {
+ * super.onCreate(savedInstanceState)
+ *
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(
+ * AuthProvider.Email(),
+ * AuthProvider.Google(...)
+ * )
+ * }
+ *
+ * authController = authUI.createAuthFlow(configuration)
+ *
+ * // Observe auth state
+ * lifecycleScope.launch {
+ * authController.authStateFlow.collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User signed in successfully
+ * val user = state.user
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * }
+ * is AuthState.Cancelled -> {
+ * // User cancelled
+ * }
+ * else -> {}
+ * }
+ * }
+ * }
+ *
+ * // Start auth flow
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * }
+ *
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * }
+ * ```
+ *
+ * **Lifecycle Management:**
+ * - [createIntent] - Generate Intent to start the auth flow Activity
+ * - [start] - Alternative to launch the flow (for Activity context)
+ * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled]
+ * - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy()
+ *
+ * @property authUI The [FirebaseAuthUI] instance managing authentication
+ * @property configuration The [AuthUIConfiguration] defining the auth flow behavior
+ *
+ * @since 10.0.0
+ */
+class AuthFlowController internal constructor(
+ private val authUI: FirebaseAuthUI,
+ private val configuration: AuthUIConfiguration
+) {
+
+ private val coroutineScope = CoroutineScope(Dispatchers.Main + Job())
+ private val isDisposed = AtomicBoolean(false)
+ private var stateCollectionJob: Job? = null
+
+ /**
+ * Flow of [AuthState] changes during the authentication flow.
+ *
+ * Subscribe to this flow to observe authentication state changes.
+ * The flow is backed by the [FirebaseAuthUI.authStateFlow] and will
+ * emit states like:
+ * - [AuthState.Idle] - No active authentication
+ * - [AuthState.Loading] - Authentication in progress
+ * - [AuthState.Success] - User signed in successfully
+ * - [AuthState.Error] - Authentication error occurred
+ * - [AuthState.Cancelled] - User cancelled the flow
+ * - [AuthState.RequiresMfa] - Multi-factor authentication required
+ * - [AuthState.RequiresEmailVerification] - Email verification required
+ */
+ val authStateFlow: Flow
+ get() {
+ checkNotDisposed()
+ return authUI.authStateFlow()
+ }
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * Use this method with [ActivityResultLauncher] to start the auth flow
+ * and handle the result in a lifecycle-aware manner.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * // Auth flow completed successfully
+ * } else {
+ * // Auth flow cancelled or error
+ * }
+ * }
+ *
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * ```
+ *
+ * @param context Android [Context] to create the Intent
+ * @return [Intent] configured to launch the auth flow Activity
+ * @throws IllegalStateException if the controller has been disposed
+ */
+ fun createIntent(context: Context): Intent {
+ checkNotDisposed()
+ return FirebaseAuthActivity.createIntent(context, configuration)
+ }
+
+ /**
+ * Starts the Firebase authentication flow.
+ *
+ * This method launches the auth flow Activity from the provided [Activity] context.
+ * For better lifecycle management, prefer using [createIntent] with
+ * [ActivityResultLauncher] instead.
+ *
+ * **Note:** This method uses [Activity.startActivityForResult] which is deprecated.
+ * Consider using [createIntent] with the Activity Result API instead.
+ *
+ * @param activity The [Activity] to launch from
+ * @param requestCode Request code for [Activity.onActivityResult]
+ * @throws IllegalStateException if the controller has been disposed
+ *
+ * @see createIntent
+ */
+ @Deprecated(
+ message = "Use createIntent() with ActivityResultLauncher instead",
+ replaceWith = ReplaceWith("createIntent(activity)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun start(activity: Activity, requestCode: Int = RC_SIGN_IN) {
+ checkNotDisposed()
+ val intent = createIntent(activity)
+ activity.startActivityForResult(intent, requestCode)
+ }
+
+ /**
+ * Cancels the ongoing authentication flow.
+ *
+ * This method transitions the auth state to [AuthState.Cancelled] and
+ * signals the auth flow to terminate. The auth flow Activity will finish
+ * and return [Activity.RESULT_CANCELED].
+ *
+ * **Example:**
+ * ```kotlin
+ * // User clicked a "Cancel" button
+ * cancelButton.setOnClickListener {
+ * authController.cancel()
+ * }
+ * ```
+ *
+ * @throws IllegalStateException if the controller has been disposed
+ */
+ fun cancel() {
+ checkNotDisposed()
+ authUI.updateAuthState(AuthState.Cancelled)
+ }
+
+ /**
+ * Disposes the controller and releases all resources.
+ *
+ * This method:
+ * - Cancels all coroutines in the controller scope
+ * - Stops listening to auth state changes
+ * - Marks the controller as disposed
+ *
+ * Call this method in your Activity's `onDestroy()` to prevent memory leaks.
+ *
+ * **Important:** Once disposed, this controller cannot be reused. Create a new
+ * controller if you need to start another auth flow.
+ *
+ * **Example:**
+ * ```kotlin
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * ```
+ *
+ * @throws IllegalStateException if already disposed (when called multiple times)
+ */
+ fun dispose() {
+ if (isDisposed.compareAndSet(false, true)) {
+ stateCollectionJob?.cancel()
+ coroutineScope.cancel()
+ }
+ }
+
+ /**
+ * Checks if the controller has been disposed.
+ *
+ * @return `true` if disposed, `false` otherwise
+ */
+ fun isDisposed(): Boolean = isDisposed.get()
+
+ private fun checkNotDisposed() {
+ check(!isDisposed.get()) {
+ "AuthFlowController has been disposed. Create a new controller to start another auth flow."
+ }
+ }
+
+ internal fun startStateCollection() {
+ if (stateCollectionJob == null || stateCollectionJob?.isActive == false) {
+ stateCollectionJob = authUI.authStateFlow()
+ .onEach { state ->
+ // Optional: Add logging or side effects here
+ }
+ .launchIn(coroutineScope)
+ }
+ }
+
+ companion object {
+ /**
+ * Request code for the sign-in activity result.
+ *
+ * Use this constant when calling [start] with `startActivityForResult`.
+ */
+ const val RC_SIGN_IN = 9001
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthMethodPickerLayout.java b/auth/src/main/java/com/firebase/ui/auth/AuthMethodPickerLayout.java
deleted file mode 100644
index 3644f2bcc..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/AuthMethodPickerLayout.java
+++ /dev/null
@@ -1,211 +0,0 @@
-package com.firebase.ui.auth;
-
-import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
-
-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 java.util.HashMap;
-import java.util.Map;
-
-import androidx.annotation.IdRes;
-import androidx.annotation.LayoutRes;
-import androidx.annotation.NonNull;
-
-/**
- * Layout model to help customizing layout of the AuthMethodPickerActivity screen,
- * where the user is presented with a list of sign-in providers to choose from.
- *
- * To create a new instance, use {@link AuthMethodPickerLayout.Builder}.
- */
-public class AuthMethodPickerLayout implements Parcelable {
-
- @LayoutRes
- private int mainLayout;
-
- @IdRes
- private int tosPpView = -1;
-
- /**
- * PROVIDER_ID -> IdRes of the Button
- */
- private Map providersButton;
-
- private AuthMethodPickerLayout() {}
-
- private AuthMethodPickerLayout(@NonNull Parcel in) {
- this.mainLayout = in.readInt();
- this.tosPpView = in.readInt();
-
- Bundle buttonsBundle = in.readBundle(getClass().getClassLoader());
- this.providersButton = new HashMap<>();
- for (String key : buttonsBundle.keySet()) {
- this.providersButton.put(key, buttonsBundle.getInt(key));
- }
- }
-
- @LayoutRes
- public int getMainLayout() {
- return mainLayout;
- }
-
- @IdRes
- public int getTosPpView() {
- return tosPpView;
- }
-
- public Map getProvidersButton() {
- return providersButton;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel parcel, int flags) {
- parcel.writeInt(mainLayout);
- parcel.writeInt(tosPpView);
-
- Bundle bundle = new Bundle();
- for (String key : providersButton.keySet()) {
- bundle.putInt(key, providersButton.get(key));
- }
- parcel.writeBundle(bundle);
- }
-
- public static final Creator CREATOR = new Creator() {
-
- @Override
- public AuthMethodPickerLayout createFromParcel(Parcel in) {
- return new AuthMethodPickerLayout(in);
- }
-
- @Override
- public AuthMethodPickerLayout[] newArray(int size) {
- return new AuthMethodPickerLayout[size];
- }
- };
-
- /**
- * Builder for {@link AuthMethodPickerLayout}.
- */
- public static class Builder {
-
- private Map providersMapping;
- private AuthMethodPickerLayout instance;
-
- /**
- * Create a new builder, specifying the ID of the XML layout resource to be sued.
- */
- public Builder(@LayoutRes int mainLayout) {
- instance = new AuthMethodPickerLayout();
- instance.mainLayout = mainLayout;
- providersMapping = new HashMap<>();
- }
-
- /**
- * Set the ID of the Google sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setGoogleButtonId(@IdRes int googleBtn) {
- providersMapping.put(GoogleAuthProvider.PROVIDER_ID, googleBtn);
- return this;
- }
-
- /**
- * Set the ID of the Facebook sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setFacebookButtonId(@IdRes int facebookBtn) {
- providersMapping.put(FacebookAuthProvider.PROVIDER_ID, facebookBtn);
- return this;
- }
-
- /**
- * Set the ID of the Twitter sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setTwitterButtonId(@IdRes int twitterBtn) {
- providersMapping.put(TwitterAuthProvider.PROVIDER_ID, twitterBtn);
- return this;
- }
-
- /**
- * Set the ID of the Email sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setEmailButtonId(@IdRes int emailButton) {
- providersMapping.put(EmailAuthProvider.PROVIDER_ID, emailButton);
- return this;
- }
-
- /**
- * Set the ID of the Phone Number sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setPhoneButtonId(@IdRes int phoneButton) {
- providersMapping.put(PhoneAuthProvider.PROVIDER_ID, phoneButton);
- return this;
- }
-
- /**
- * Set the ID of the Anonymous sign in button in the custom layout.
- */
- public AuthMethodPickerLayout.Builder setAnonymousButtonId(@IdRes int anonymousButton) {
- providersMapping.put(AuthUI.ANONYMOUS_PROVIDER, anonymousButton);
- return this;
- }
-
- public AuthMethodPickerLayout.Builder setGithubButtonId(
- @IdRes int githubButtonId) {
- providersMapping.put(GithubAuthProvider.PROVIDER_ID, githubButtonId);
- return this;
- }
-
- public AuthMethodPickerLayout.Builder setMicrosoftButtonId(
- @IdRes int microsoftButtonId) {
- providersMapping.put(AuthUI.MICROSOFT_PROVIDER, microsoftButtonId);
- return this;
- }
-
- public AuthMethodPickerLayout.Builder setAppleButtonId(
- @IdRes int appleButtonId) {
- providersMapping.put(AuthUI.APPLE_PROVIDER, appleButtonId);
- return this;
- }
-
- public AuthMethodPickerLayout.Builder setYahooButtonId(
- @IdRes int yahooButtonId) {
- providersMapping.put(AuthUI.YAHOO_PROVIDER, yahooButtonId);
- return this;
- }
-
- /**
- * Set the ID of a TextView where terms of service and privacy policy should be
- * displayed.
- */
- public AuthMethodPickerLayout.Builder setTosAndPrivacyPolicyId(@IdRes int tosPpView) {
- instance.tosPpView = tosPpView;
- return this;
- }
-
- public AuthMethodPickerLayout build() {
- if (providersMapping.isEmpty()) {
- throw new IllegalArgumentException("Must configure at least one button.");
- }
-
- for (String key : providersMapping.keySet()) {
- if (!AuthUI.SUPPORTED_PROVIDERS.contains(key)
- && !AuthUI.SUPPORTED_OAUTH_PROVIDERS.contains(key)) {
- throw new IllegalArgumentException("Unknown provider: " + key);
- }
- }
-
- instance.providersButton = providersMapping;
- return instance;
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthState.kt b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
new file mode 100644
index 000000000..061b33a45
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/AuthState.kt
@@ -0,0 +1,312 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth
+
+import com.firebase.ui.auth.AuthState.Companion.Cancelled
+import com.firebase.ui.auth.AuthState.Companion.Idle
+import com.google.firebase.auth.AuthResult
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorResolver
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthProvider
+
+/**
+ * Represents the authentication state in Firebase Auth UI.
+ *
+ * This class encapsulates all possible authentication states that can occur during
+ * the authentication flow, including success, error, and intermediate states.
+ *
+ * Use the companion object factory methods or specific subclass constructors to create instances.
+ *
+ * @since 10.0.0
+ */
+abstract class AuthState private constructor() {
+
+ /**
+ * Initial state before any authentication operation has been started.
+ */
+ class Idle internal constructor() : AuthState() {
+ override fun equals(other: Any?): Boolean = other is Idle
+ override fun hashCode(): Int = javaClass.hashCode()
+ override fun toString(): String = "AuthState.Idle"
+ }
+
+ /**
+ * Authentication operation is in progress.
+ *
+ * @property message Optional message describing what is being loaded
+ */
+ class Loading(val message: String? = null) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Loading) return false
+ return message == other.message
+ }
+
+ override fun hashCode(): Int = message?.hashCode() ?: 0
+
+ override fun toString(): String = "AuthState.Loading(message=$message)"
+ }
+
+ /**
+ * Authentication completed successfully.
+ *
+ * @property result The [AuthResult] containing the authenticated user, may be null if not available
+ * @property user The authenticated [FirebaseUser]
+ * @property isNewUser Whether this is a newly created user account
+ */
+ class Success(
+ val result: AuthResult?,
+ val user: FirebaseUser,
+ val isNewUser: Boolean = false
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Success) return false
+ return result == other.result &&
+ user == other.user &&
+ isNewUser == other.isNewUser
+ }
+
+ override fun hashCode(): Int {
+ var result1 = result?.hashCode() ?: 0
+ result1 = 31 * result1 + user.hashCode()
+ result1 = 31 * result1 + isNewUser.hashCode()
+ return result1
+ }
+
+ override fun toString(): String =
+ "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)"
+ }
+
+ /**
+ * An error occurred during authentication.
+ *
+ * @property exception The [Exception] that occurred
+ * @property isRecoverable Whether the error can be recovered from
+ */
+ class Error(
+ val exception: Exception,
+ val isRecoverable: Boolean = true
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Error) return false
+ return exception == other.exception &&
+ isRecoverable == other.isRecoverable
+ }
+
+ override fun hashCode(): Int {
+ var result = exception.hashCode()
+ result = 31 * result + isRecoverable.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.Error(exception=$exception, isRecoverable=$isRecoverable)"
+ }
+
+ /**
+ * Authentication was cancelled by the user.
+ */
+ class Cancelled internal constructor() : AuthState() {
+ override fun equals(other: Any?): Boolean = other is Cancelled
+ override fun hashCode(): Int = javaClass.hashCode()
+ override fun toString(): String = "AuthState.Cancelled"
+ }
+
+ /**
+ * Multi-factor authentication is required to complete sign-in.
+ *
+ * @property resolver The [MultiFactorResolver] to complete MFA
+ * @property hint Optional hint about which factor to use
+ */
+ class RequiresMfa(
+ val resolver: MultiFactorResolver,
+ val hint: String? = null
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is RequiresMfa) return false
+ return resolver == other.resolver &&
+ hint == other.hint
+ }
+
+ override fun hashCode(): Int {
+ var result = resolver.hashCode()
+ result = 31 * result + (hint?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.RequiresMfa(resolver=$resolver, hint=$hint)"
+ }
+
+ /**
+ * Email verification is required before the user can access the app.
+ *
+ * @property user The [FirebaseUser] who needs to verify their email
+ * @property email The email address that needs verification
+ */
+ class RequiresEmailVerification(
+ val user: FirebaseUser,
+ val email: String
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is RequiresEmailVerification) return false
+ return user == other.user &&
+ email == other.email
+ }
+
+ override fun hashCode(): Int {
+ var result = user.hashCode()
+ result = 31 * result + email.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.RequiresEmailVerification(user=$user, email=$email)"
+ }
+
+ /**
+ * The user needs to complete their profile information.
+ *
+ * @property user The [FirebaseUser] who needs to complete their profile
+ * @property missingFields List of profile fields that need to be completed
+ */
+ class RequiresProfileCompletion(
+ val user: FirebaseUser,
+ val missingFields: List = emptyList()
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is RequiresProfileCompletion) return false
+ return user == other.user &&
+ missingFields == other.missingFields
+ }
+
+ override fun hashCode(): Int {
+ var result = user.hashCode()
+ result = 31 * result + missingFields.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)"
+ }
+
+ /**
+ * Password reset link has been sent to the user's email.
+ */
+ class PasswordResetLinkSent : AuthState() {
+ override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent
+ override fun hashCode(): Int = javaClass.hashCode()
+ override fun toString(): String = "AuthState.PasswordResetLinkSent"
+ }
+
+ /**
+ * Email sign in link has been sent to the user's email.
+ */
+ class EmailSignInLinkSent : AuthState() {
+ override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
+ override fun hashCode(): Int = javaClass.hashCode()
+ override fun toString(): String = "AuthState.EmailSignInLinkSent"
+ }
+
+ /**
+ * Phone number was automatically verified via SMS instant verification.
+ *
+ * This state is emitted when Firebase Phone Authentication successfully retrieves
+ * and verifies the SMS code automatically without user interaction. This happens
+ * when Google Play services can detect the incoming SMS message.
+ *
+ * @property credential The [PhoneAuthCredential] that can be used to sign in the user
+ *
+ * @see PhoneNumberVerificationRequired for the manual verification flow
+ */
+ class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SMSAutoVerified) return false
+ return credential == other.credential
+ }
+
+ override fun hashCode(): Int {
+ var result = credential.hashCode()
+ result = 31 * result + credential.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.SMSAutoVerified(credential=$credential)"
+ }
+
+ /**
+ * Phone number verification requires manual code entry.
+ *
+ * This state is emitted when Firebase Phone Authentication cannot instantly verify
+ * the phone number and sends an SMS code that the user must manually enter. This is
+ * the normal flow when automatic SMS retrieval is not available or fails.
+ *
+ * **Resending codes:**
+ * To allow users to resend the verification code (if they didn't receive it),
+ * call [FirebaseAuthUI.verifyPhoneNumber] again with:
+ * - `isForceResendingTokenEnabled = true`
+ * - `forceResendingToken` from this state
+ *
+ * @property verificationId The verification ID to use when submitting the code.
+ * This must be passed to [FirebaseAuthUI.submitVerificationCode].
+ * @property forceResendingToken Token that can be used to resend the SMS code if needed
+ *
+ */
+ class PhoneNumberVerificationRequired(
+ val verificationId: String,
+ val forceResendingToken: PhoneAuthProvider.ForceResendingToken,
+ ) : AuthState() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is PhoneNumberVerificationRequired) return false
+ return verificationId == other.verificationId &&
+ forceResendingToken == other.forceResendingToken
+ }
+
+ override fun hashCode(): Int {
+ var result = verificationId.hashCode()
+ result = 31 * result + forceResendingToken.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "AuthState.PhoneNumberVerificationRequired(verificationId=$verificationId, " +
+ "forceResendingToken=$forceResendingToken)"
+ }
+
+ companion object {
+ /**
+ * Creates an Idle state instance.
+ * @return A new [Idle] state
+ */
+ @JvmStatic
+ val Idle: Idle = Idle()
+
+ /**
+ * Creates a Cancelled state instance.
+ * @return A new [Cancelled] state
+ */
+ @JvmStatic
+ val Cancelled: Cancelled = Cancelled()
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/AuthUI.java b/auth/src/main/java/com/firebase/ui/auth/AuthUI.java
deleted file mode 100644
index 2524335b5..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/AuthUI.java
+++ /dev/null
@@ -1,1432 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.os.CancellationSignal;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.util.Log;
-
-import com.facebook.login.LoginManager;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.idp.AuthMethodPickerActivity;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.GoogleApiUtils;
-import com.firebase.ui.auth.util.Preconditions;
-import com.firebase.ui.auth.util.data.PhoneNumberUtils;
-import com.firebase.ui.auth.util.data.ProviderAvailability;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.google.android.gms.auth.api.signin.GoogleSignIn;
-import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
-import com.google.android.gms.common.api.CommonStatusCodes;
-import com.google.android.gms.common.api.Scope;
-import com.google.android.gms.tasks.Task;
-import com.google.android.gms.tasks.TaskCompletionSource;
-import com.google.android.gms.tasks.Tasks;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.auth.ActionCodeSettings;
-import com.google.firebase.auth.EmailAuthProvider;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseAuthInvalidUserException;
-import com.google.firebase.auth.FirebaseUser;
-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 java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.IdentityHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-
-import androidx.annotation.CallSuper;
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringDef;
-import androidx.annotation.StyleRes;
-import androidx.credentials.ClearCredentialStateRequest;
-import androidx.credentials.CredentialManagerCallback;
-import androidx.credentials.exceptions.ClearCredentialException;
-
-/**
- * The entry point to the AuthUI authentication flow, and related utility methods. If your
- * application uses the default {@link FirebaseApp} instance, an AuthUI instance can be retrieved
- * simply by calling {@link AuthUI#getInstance()}. If an alternative app instance is in use, call
- * {@link AuthUI#getInstance(FirebaseApp)} instead, passing the appropriate app instance.
- *
- *
- * See the
- * README
- * for examples on how to get started with FirebaseUI Auth.
- */
-public final class AuthUI {
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static final String TAG = "AuthUI";
-
- /**
- * Provider for anonymous users.
- */
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static final String ANONYMOUS_PROVIDER = "anonymous";
- public static final String EMAIL_LINK_PROVIDER = EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD;
-
- public static final String MICROSOFT_PROVIDER = "microsoft.com";
- public static final String YAHOO_PROVIDER = "yahoo.com";
- public static final String APPLE_PROVIDER = "apple.com";
-
- /**
- * Default value for logo resource, omits the logo from the {@link AuthMethodPickerActivity}.
- */
- public static final int NO_LOGO = -1;
-
- /**
- * The set of authentication providers supported in Firebase Auth UI.
- */
- public static final Set SUPPORTED_PROVIDERS =
- Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
- GoogleAuthProvider.PROVIDER_ID,
- FacebookAuthProvider.PROVIDER_ID,
- TwitterAuthProvider.PROVIDER_ID,
- GithubAuthProvider.PROVIDER_ID,
- EmailAuthProvider.PROVIDER_ID,
- PhoneAuthProvider.PROVIDER_ID,
- ANONYMOUS_PROVIDER,
- EMAIL_LINK_PROVIDER
- )));
-
- /**
- * The set of OAuth2.0 providers supported in Firebase Auth UI through Generic IDP (web flow).
- */
- public static final Set SUPPORTED_OAUTH_PROVIDERS =
- Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
- MICROSOFT_PROVIDER,
- YAHOO_PROVIDER,
- APPLE_PROVIDER,
- TwitterAuthProvider.PROVIDER_ID,
- GithubAuthProvider.PROVIDER_ID
- )));
-
- /**
- * The set of social authentication providers supported in Firebase Auth UI using their SDK.
- */
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static final Set SOCIAL_PROVIDERS =
- Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
- GoogleAuthProvider.PROVIDER_ID,
- FacebookAuthProvider.PROVIDER_ID)));
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static final String UNCONFIGURED_CONFIG_VALUE = "CHANGE-ME";
-
- private static final IdentityHashMap INSTANCES = new IdentityHashMap<>();
-
- private static Context sApplicationContext;
-
- private final FirebaseApp mApp;
- private final FirebaseAuth mAuth;
-
- private String mEmulatorHost = null;
- private int mEmulatorPort = -1;
-
- private AuthUI(FirebaseApp app) {
- mApp = app;
- mAuth = FirebaseAuth.getInstance(mApp);
-
- try {
- mAuth.setFirebaseUIVersion(BuildConfig.VERSION_NAME);
- } catch (Exception e) {
- Log.e(TAG, "Couldn't set the FUI version.", e);
- }
- mAuth.useAppLanguage();
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- @NonNull
- public static Context getApplicationContext() {
- return sApplicationContext;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static void setApplicationContext(@NonNull Context context) {
- sApplicationContext = Preconditions.checkNotNull(context, "App context cannot be null.")
- .getApplicationContext();
- }
-
- /**
- * Retrieves the {@link AuthUI} instance associated with the default app, as returned by {@code
- * FirebaseApp.getInstance()}.
- *
- * @throws IllegalStateException if the default app is not initialized.
- */
- @NonNull
- public static AuthUI getInstance() {
- return getInstance(FirebaseApp.getInstance());
- }
-
- /**
- * Retrieves the {@link AuthUI} instance associated the the specified app name.
- *
- * @throws IllegalStateException if the app is not initialized.
- */
- @NonNull
- public static AuthUI getInstance(@NonNull String appName) {
- return getInstance(FirebaseApp.getInstance(appName));
- }
-
- /**
- * Retrieves the {@link AuthUI} instance associated the the specified app.
- */
- @NonNull
- public static AuthUI getInstance(@NonNull FirebaseApp app) {
- String releaseUrl = "https://github.com/firebase/FirebaseUI-Android/releases/tag/6.2.0";
- String devWarning = "Beginning with FirebaseUI 6.2.0 you no longer need to include %s to " +
- "sign in with %s. Go to %s for more information";
- if (ProviderAvailability.IS_TWITTER_AVAILABLE) {
- Log.w(TAG, String.format(devWarning, "the TwitterKit SDK", "Twitter", releaseUrl));
- }
- if (ProviderAvailability.IS_GITHUB_AVAILABLE) {
- Log.w(TAG, String.format(devWarning, "com.firebaseui:firebase-ui-auth-github",
- "GitHub", releaseUrl));
- }
-
- AuthUI authUi;
- synchronized (INSTANCES) {
- authUi = INSTANCES.get(app);
- if (authUi == null) {
- authUi = new AuthUI(app);
- INSTANCES.put(app, authUi);
- }
- }
- return authUi;
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseApp getApp() {
- return mApp;
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseAuth getAuth() {
- return mAuth;
- }
-
- /**
- * Returns true if AuthUI can handle the intent.
- *
- * AuthUI handle the intent when the embedded data is an email link. If it is, you can then
- * specify the link in {@link SignInIntentBuilder#setEmailLink(String)} before starting AuthUI
- * and it will be handled immediately.
- */
- public static boolean canHandleIntent(@NonNull Intent intent) {
- if (intent == null || intent.getData() == null) {
- return false;
- }
- String link = intent.getData().toString();
- return FirebaseAuth.getInstance().isSignInWithEmailLink(link);
- }
-
- /**
- * Default theme used by {@link SignInIntentBuilder#setTheme(int)} if no theme customization is
- * required.
- */
- @StyleRes
- public static int getDefaultTheme() {
- return R.style.FirebaseUI_DefaultMaterialTheme;
- }
-
- /**
- * Signs the current user out, if one is signed in.
- *
- * @param context the context requesting the user be signed out
- * @return A task which, upon completion, signals that the user has been signed out ({@link
- * Task#isSuccessful()}, or that the sign-out attempt failed unexpectedly !{@link
- * Task#isSuccessful()}).
- */
- @NonNull
- public Task signOut(@NonNull Context context) {
- boolean playServicesAvailable = GoogleApiUtils.isPlayServicesAvailable(context);
- if (!playServicesAvailable) {
- Log.w(TAG, "Google Play services not available during signOut");
- }
- signOutIdps(context);
- Executor singleThreadExecutor = Executors.newSingleThreadExecutor();
- return clearCredentialState(context, singleThreadExecutor).continueWith(task -> {
- task.getResult(); // Propagate exceptions if any.
- mAuth.signOut();
- return null;
- });
- }
-
- /**
- * Delete the user from FirebaseAuth.
- *
- *
Any associated saved credentials are not explicitly deleted with the new APIs.
- *
- * @param context the calling {@link Context}.
- */
- @NonNull
- public Task delete(@NonNull final Context context) {
- final FirebaseUser currentUser = mAuth.getCurrentUser();
- if (currentUser == null) {
- return Tasks.forException(new FirebaseAuthInvalidUserException(
- String.valueOf(CommonStatusCodes.SIGN_IN_REQUIRED),
- "No currently signed in user."));
- }
- signOutIdps(context);
- Executor singleThreadExecutor = Executors.newSingleThreadExecutor();
- return clearCredentialState(context, singleThreadExecutor).continueWithTask(task -> {
- task.getResult(); // Propagate exceptions if any.
- return currentUser.delete();
- });
- }
-
- /**
- * Connect to the Firebase Authentication emulator.
- * @see FirebaseAuth#useEmulator(String, int)
- */
- public void useEmulator(@NonNull String host, int port) {
- Preconditions.checkArgument(port >= 0, "Port must be >= 0");
- Preconditions.checkArgument(port <= 65535, "Port must be <= 65535");
- mEmulatorHost = host;
- mEmulatorPort = port;
-
- mAuth.useEmulator(host, port);
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public boolean isUseEmulator() {
- return mEmulatorHost != null && mEmulatorPort >= 0;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public String getEmulatorHost() {
- return mEmulatorHost;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public int getEmulatorPort() {
- return mEmulatorPort;
- }
-
- private void signOutIdps(@NonNull Context context) {
- if (ProviderAvailability.IS_FACEBOOK_AVAILABLE) {
- LoginManager.getInstance().logOut();
- }
- }
-
- /**
- * A Task to clear the credential state in Credential Manager.
- * @param context
- * @param executor
- * @return
- */
- private Task clearCredentialState(
- @NonNull Context context,
- @NonNull Executor executor
- ) {
- TaskCompletionSource completionSource = new TaskCompletionSource<>();
-
- ClearCredentialStateRequest clearRequest = new ClearCredentialStateRequest();
- GoogleApiUtils.getCredentialManager(context)
- .clearCredentialStateAsync(
- clearRequest,
- new CancellationSignal(),
- executor,
- new CredentialManagerCallback<>() {
- @Override
- public void onResult(Void unused) {
- completionSource.setResult(unused);
- }
-
- @Override
- public void onError(@NonNull ClearCredentialException e) {
- completionSource.setException(e);
- }
- }
- );
- return completionSource.getTask();
- }
-
- /**
- * Starts the process of creating a sign in intent, with the mandatory application context
- * parameter.
- */
- @NonNull
- public SignInIntentBuilder createSignInIntentBuilder() {
- return new SignInIntentBuilder();
- }
-
- @StringDef({
- GoogleAuthProvider.PROVIDER_ID,
- FacebookAuthProvider.PROVIDER_ID,
- TwitterAuthProvider.PROVIDER_ID,
- GithubAuthProvider.PROVIDER_ID,
- EmailAuthProvider.PROVIDER_ID,
- PhoneAuthProvider.PROVIDER_ID,
- ANONYMOUS_PROVIDER,
- EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD
- })
- @Retention(RetentionPolicy.SOURCE)
- public @interface SupportedProvider {
- }
-
- /**
- * Configuration for an identity provider.
- */
- public static final class IdpConfig implements Parcelable {
- public static final Creator CREATOR = new Creator() {
- @Override
- public IdpConfig createFromParcel(Parcel in) {
- return new IdpConfig(in);
- }
-
- @Override
- public IdpConfig[] newArray(int size) {
- return new IdpConfig[size];
- }
- };
-
- private final String mProviderId;
- private final Bundle mParams;
-
- private IdpConfig(
- @SupportedProvider @NonNull String providerId,
- @NonNull Bundle params) {
- mProviderId = providerId;
- mParams = new Bundle(params);
- }
-
- private IdpConfig(Parcel in) {
- mProviderId = in.readString();
- mParams = in.readBundle(getClass().getClassLoader());
- }
-
- @NonNull
- @SupportedProvider
- public String getProviderId() {
- return mProviderId;
- }
-
- /**
- * @return provider-specific options
- */
- @NonNull
- public Bundle getParams() {
- return new Bundle(mParams);
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel parcel, int i) {
- parcel.writeString(mProviderId);
- parcel.writeBundle(mParams);
- }
-
- @Override
- public final boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- IdpConfig config = (IdpConfig) o;
-
- return mProviderId.equals(config.mProviderId);
- }
-
- @Override
- public final int hashCode() {
- return mProviderId.hashCode();
- }
-
- @Override
- public String toString() {
- return "IdpConfig{" +
- "mProviderId='" + mProviderId + '\'' +
- ", mParams=" + mParams +
- '}';
- }
-
- /**
- * Base builder for all authentication providers.
- *
- * @see SignInIntentBuilder#setAvailableProviders(List)
- */
- public static class Builder {
- private final Bundle mParams = new Bundle();
- @SupportedProvider
- private String mProviderId;
-
- protected Builder(@SupportedProvider @NonNull String providerId) {
- if (!SUPPORTED_PROVIDERS.contains(providerId)
- && !SUPPORTED_OAUTH_PROVIDERS.contains(providerId)) {
- throw new IllegalArgumentException("Unknown provider: " + providerId);
- }
- mProviderId = providerId;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- @NonNull
- protected final Bundle getParams() {
- return mParams;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- protected void setProviderId(@NonNull String providerId) {
- mProviderId = providerId;
- }
-
- @CallSuper
- @NonNull
- public IdpConfig build() {
- return new IdpConfig(mProviderId, mParams);
- }
- }
-
- /**
- * {@link IdpConfig} builder for the email provider.
- */
- public static final class EmailBuilder extends Builder {
- public EmailBuilder() {
- super(EmailAuthProvider.PROVIDER_ID);
- }
-
- /**
- * Enables or disables creating new accounts in the email sign in flows.
- *
- * Account creation is enabled by default.
- */
- @NonNull
- public EmailBuilder setAllowNewAccounts(boolean allow) {
- getParams().putBoolean(ExtraConstants.ALLOW_NEW_EMAILS, allow);
- return this;
- }
-
- /**
- * Configures the requirement for the user to enter first and last name in the email
- * sign up flow.
- *
- * Name is required by default.
- */
- @NonNull
- public EmailBuilder setRequireName(boolean requireName) {
- getParams().putBoolean(ExtraConstants.REQUIRE_NAME, requireName);
- return this;
- }
-
- /**
- * Enables email link sign in instead of password based sign in. Once enabled, you must
- * pass a valid {@link ActionCodeSettings} object using
- * {@link #setActionCodeSettings(ActionCodeSettings)}
- *
- * You must enable Firebase Dynamic Links in the Firebase Console to use email link
- * sign in.
- *
- * @throws IllegalStateException if {@link ActionCodeSettings} is null or not
- * provided with email link enabled.
- */
- @NonNull
- public EmailBuilder enableEmailLinkSignIn() {
- setProviderId(EMAIL_LINK_PROVIDER);
- return this;
- }
-
- /**
- * Sets the {@link ActionCodeSettings} object to be used for email link sign in.
- *
- * {@link ActionCodeSettings#canHandleCodeInApp()} must be set to true, and a valid
- * continueUrl must be passed via {@link ActionCodeSettings.Builder#setUrl(String)}.
- * This URL must be allowlisted in the Firebase Console.
- *
- * @throws IllegalStateException if canHandleCodeInApp is set to false
- * @throws NullPointerException if ActionCodeSettings is null
- */
- @NonNull
- public EmailBuilder setActionCodeSettings(ActionCodeSettings actionCodeSettings) {
- getParams().putParcelable(ExtraConstants.ACTION_CODE_SETTINGS, actionCodeSettings);
- return this;
- }
-
- /**
- * Disables allowing email link sign in to occur across different devices.
- *
- * This cannot be disabled with anonymous upgrade.
- */
- @NonNull
- public EmailBuilder setForceSameDevice() {
- getParams().putBoolean(ExtraConstants.FORCE_SAME_DEVICE, true);
- return this;
- }
-
- /**
- * Sets a default sign in email, if the given email has been registered before, then
- * it will ask the user for password, if the given email it's not registered, then
- * it starts signing up the default email.
- */
- @NonNull
- public EmailBuilder setDefaultEmail(String email) {
- getParams().putString(ExtraConstants.DEFAULT_EMAIL, email);
- return this;
- }
-
- @Override
- public IdpConfig build() {
- if (super.mProviderId.equals(EMAIL_LINK_PROVIDER)) {
- ActionCodeSettings actionCodeSettings =
- getParams().getParcelable(ExtraConstants.ACTION_CODE_SETTINGS);
- Preconditions.checkNotNull(actionCodeSettings, "ActionCodeSettings cannot be " +
- "null when using email link sign in.");
- if (!actionCodeSettings.canHandleCodeInApp()) {
- // Pre-emptively fail if actionCodeSettings are misconfigured. This would
- // have happened when calling sendSignInLinkToEmail
- throw new IllegalStateException(
- "You must set canHandleCodeInApp in your ActionCodeSettings to " +
- "true for Email-Link Sign-in.");
- }
- }
- return super.build();
- }
- }
-
- /**
- * {@link IdpConfig} builder for the phone provider.
- */
- public static final class PhoneBuilder extends Builder {
- public PhoneBuilder() {
- super(PhoneAuthProvider.PROVIDER_ID);
- }
-
- /**
- * @param number the phone number in international format
- * @see #setDefaultNumber(String, String)
- */
- @NonNull
- public PhoneBuilder setDefaultNumber(@NonNull String number) {
- Preconditions.checkUnset(getParams(),
- "Cannot overwrite previously set phone number",
- ExtraConstants.PHONE,
- ExtraConstants.COUNTRY_ISO,
- ExtraConstants.NATIONAL_NUMBER);
- if (!PhoneNumberUtils.isValid(number)) {
- throw new IllegalStateException("Invalid phone number: " + number);
- }
-
- getParams().putString(ExtraConstants.PHONE, number);
-
- return this;
- }
-
- /**
- * Set the default phone number that will be used to populate the phone verification
- * sign-in flow.
- *
- * @param iso the phone number's country code
- * @param number the phone number in local format
- */
- @NonNull
- public PhoneBuilder setDefaultNumber(@NonNull String iso, @NonNull String number) {
- Preconditions.checkUnset(getParams(),
- "Cannot overwrite previously set phone number",
- ExtraConstants.PHONE,
- ExtraConstants.COUNTRY_ISO,
- ExtraConstants.NATIONAL_NUMBER);
- if (!PhoneNumberUtils.isValidIso(iso)) {
- throw new IllegalStateException("Invalid country iso: " + iso);
- }
-
- getParams().putString(ExtraConstants.COUNTRY_ISO, iso);
- getParams().putString(ExtraConstants.NATIONAL_NUMBER, number);
-
- return this;
- }
-
- /**
- * Set the default country code that will be used in the phone verification sign-in
- * flow.
- *
- * @param iso country iso
- */
- @NonNull
- public PhoneBuilder setDefaultCountryIso(@NonNull String iso) {
- Preconditions.checkUnset(getParams(),
- "Cannot overwrite previously set phone number",
- ExtraConstants.PHONE,
- ExtraConstants.COUNTRY_ISO,
- ExtraConstants.NATIONAL_NUMBER);
- if (!PhoneNumberUtils.isValidIso(iso)) {
- throw new IllegalStateException("Invalid country iso: " + iso);
- }
-
- getParams().putString(ExtraConstants.COUNTRY_ISO,
- iso.toUpperCase(Locale.getDefault()));
-
- return this;
- }
-
-
- /**
- * Sets the country codes available in the country code selector for phone
- * authentication. Takes as input a List of both country isos and codes.
- * This is not to be called with
- * {@link #setBlockedCountries(List)}.
- * If both are called, an exception will be thrown.
- *
- * Inputting an e-164 country code (e.g. '+1') will include all countries with
- * +1 as its code.
- * Example input: {'+52', 'us'}
- * For a list of country iso or codes, see Alpha-2 isos here:
- * https://en.wikipedia.org/wiki/ISO_3166-1
- * and e-164 codes here: https://en.wikipedia.org/wiki/List_of_country_calling_codes
- *
- * @param countries a non empty case insensitive list of country codes
- * and/or isos to be allowlisted
- * @throws IllegalArgumentException if an empty allowlist is provided.
- * @throws NullPointerException if a null allowlist is provided.
- */
- public PhoneBuilder setAllowedCountries(
- @NonNull List countries) {
- if (getParams().containsKey(ExtraConstants.BLOCKLISTED_COUNTRIES)) {
- throw new IllegalStateException(
- "You can either allowlist or blocklist country codes for phone " +
- "authentication.");
- }
-
- String message = "Invalid argument: Only non-%s allowlists are valid. " +
- "To specify no allowlist, do not call this method.";
- Preconditions.checkNotNull(countries, String.format(message, "null"));
- Preconditions.checkArgument(!countries.isEmpty(), String.format
- (message, "empty"));
-
- addCountriesToBundle(countries, ExtraConstants.ALLOWLISTED_COUNTRIES);
- return this;
- }
-
- /**
- * Sets the countries to be removed from the country code selector for phone
- * authentication. Takes as input a List of both country isos and codes.
- * This is not to be called with
- * {@link #setAllowedCountries(List)}.
- * If both are called, an exception will be thrown.
- *
- * Inputting an e-164 country code (e.g. '+1') will include all countries with
- * +1 as its code.
- * Example input: {'+52', 'us'}
- * For a list of country iso or codes, see Alpha-2 codes here:
- * https://en.wikipedia.org/wiki/ISO_3166-1
- * and e-164 codes here: https://en.wikipedia.org/wiki/List_of_country_calling_codes
- *
- * @param countries a non empty case insensitive list of country codes
- * and/or isos to be blocklisted
- * @throws IllegalArgumentException if an empty blocklist is provided.
- * @throws NullPointerException if a null blocklist is provided.
- */
- public PhoneBuilder setBlockedCountries(
- @NonNull List countries) {
- if (getParams().containsKey(ExtraConstants.ALLOWLISTED_COUNTRIES)) {
- throw new IllegalStateException(
- "You can either allowlist or blocklist country codes for phone " +
- "authentication.");
- }
-
- String message = "Invalid argument: Only non-%s blocklists are valid. " +
- "To specify no blocklist, do not call this method.";
- Preconditions.checkNotNull(countries, String.format(message, "null"));
- Preconditions.checkArgument(!countries.isEmpty(), String.format
- (message, "empty"));
-
- addCountriesToBundle(countries, ExtraConstants.BLOCKLISTED_COUNTRIES);
- return this;
- }
-
- @Override
- public IdpConfig build() {
- validateInputs();
- return super.build();
- }
-
- private void addCountriesToBundle(List CountryIsos, String CountryIsoType) {
- ArrayList uppercaseCodes = new ArrayList<>();
- for (String code : CountryIsos) {
- uppercaseCodes.add(code.toUpperCase(Locale.getDefault()));
- }
-
- getParams().putStringArrayList(CountryIsoType, uppercaseCodes);
- }
-
- private void validateInputs() {
- List allowedCountries = getParams().getStringArrayList(
- ExtraConstants.ALLOWLISTED_COUNTRIES);
- List blockedCountries = getParams().getStringArrayList(
- ExtraConstants.BLOCKLISTED_COUNTRIES);
-
- if (allowedCountries != null && blockedCountries != null) {
- throw new IllegalStateException(
- "You can either allowlist or blocked country codes for phone " +
- "authentication.");
- } else if (allowedCountries != null) {
- validateInputs(allowedCountries, true);
-
- } else if (blockedCountries != null) {
- validateInputs(blockedCountries, false);
- }
- }
-
- private void validateInputs(List countries, boolean allowed) {
- validateCountryInput(countries);
- validateDefaultCountryInput(countries, allowed);
- }
-
- private void validateCountryInput(List codes) {
- for (String code : codes) {
- if (!PhoneNumberUtils.isValidIso(code) && !PhoneNumberUtils.isValid(code)) {
- throw new IllegalArgumentException("Invalid input: You must provide a " +
- "valid country iso (alpha-2) or code (e-164). e.g. 'us' or '+1'.");
- }
- }
- }
-
- private void validateDefaultCountryInput(List codes, boolean allowed) {
- // A default iso/code can be set via #setDefaultCountryIso() or #setDefaultNumber()
- if (getParams().containsKey(ExtraConstants.COUNTRY_ISO) ||
- getParams().containsKey(ExtraConstants.PHONE)) {
-
- if (!validateDefaultCountryIso(codes, allowed)
- || !validateDefaultPhoneIsos(codes, allowed)) {
- throw new IllegalArgumentException("Invalid default country iso. Make " +
- "sure it is either part of the allowed list or that you "
- + "haven't blocked it.");
- }
- }
-
- }
-
- private boolean validateDefaultCountryIso(List codes, boolean allowed) {
- String defaultIso = getDefaultIso();
- return isValidDefaultIso(codes, defaultIso, allowed);
- }
-
- private boolean validateDefaultPhoneIsos(List codes, boolean allowed) {
- List phoneIsos = getPhoneIsosFromCode();
- for (String iso : phoneIsos) {
- if (isValidDefaultIso(codes, iso, allowed)) {
- return true;
- }
- }
- return phoneIsos.isEmpty();
- }
-
- private boolean isValidDefaultIso(List codes, String iso, boolean allowed) {
- if (iso == null) return true;
- boolean containsIso = containsCountryIso(codes, iso);
- return containsIso && allowed || !containsIso && !allowed;
-
- }
-
- private boolean containsCountryIso(List codes, String iso) {
- iso = iso.toUpperCase(Locale.getDefault());
- for (String code : codes) {
- if (PhoneNumberUtils.isValidIso(code)) {
- if (code.equals(iso)) {
- return true;
- }
- } else {
- List isos = PhoneNumberUtils.getCountryIsosFromCountryCode(code);
- if (isos.contains(iso)) {
- return true;
- }
- }
- }
- return false;
- }
-
- private List getPhoneIsosFromCode() {
- List isos = new ArrayList<>();
- String phone = getParams().getString(ExtraConstants.PHONE);
- if (phone != null && phone.startsWith("+")) {
- String countryCode = "+" + PhoneNumberUtils.getPhoneNumber(phone)
- .getCountryCode();
- List isosToAdd = PhoneNumberUtils.
- getCountryIsosFromCountryCode(countryCode);
- if (isosToAdd != null) {
- isos.addAll(isosToAdd);
- }
- }
- return isos;
- }
-
- private String getDefaultIso() {
- return getParams().containsKey(ExtraConstants.COUNTRY_ISO) ?
- getParams().getString(ExtraConstants.COUNTRY_ISO) : null;
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Google provider.
- */
- public static final class GoogleBuilder extends Builder {
- public GoogleBuilder() {
- super(GoogleAuthProvider.PROVIDER_ID);
- }
-
- private void validateWebClientId() {
- Preconditions.checkConfigured(getApplicationContext(),
- "Check your google-services plugin configuration, the" +
- " default_web_client_id string wasn't populated.",
- R.string.default_web_client_id);
- }
-
- /**
- * Set the scopes that your app will request when using Google sign-in. See all available
- * scopes.
- *
- * @param scopes additional scopes to be requested
- */
- @NonNull
- public GoogleBuilder setScopes(@NonNull List scopes) {
- GoogleSignInOptions.Builder builder =
- new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
- .requestEmail();
- for (String scope : scopes) {
- builder.requestScopes(new Scope(scope));
- }
- return setSignInOptions(builder.build());
- }
-
- /**
- * Set the {@link GoogleSignInOptions} to be used for Google sign-in. Standard
- * options like requesting the user's email will automatically be added.
- *
- * @param options sign-in options
- */
- @NonNull
- public GoogleBuilder setSignInOptions(@NonNull GoogleSignInOptions options) {
- Preconditions.checkUnset(getParams(),
- "Cannot overwrite previously set sign-in options.",
- ExtraConstants.GOOGLE_SIGN_IN_OPTIONS);
-
- GoogleSignInOptions.Builder builder = new GoogleSignInOptions.Builder(options);
-
- String clientId = options.getServerClientId();
- if (clientId == null) {
- validateWebClientId();
- clientId = getApplicationContext().getString(R.string.default_web_client_id);
- }
-
- // Warn the user that they are _probably_ doing the wrong thing if they
- // have not called requestEmail (see issue #1899 and #1621)
- boolean hasEmailScope = false;
- for (Scope s : options.getScopes()) {
- if ("email".equals(s.getScopeUri())) {
- hasEmailScope = true;
- break;
- }
- }
- if (!hasEmailScope) {
- Log.w(TAG, "The GoogleSignInOptions passed to setSignInOptions does not " +
- "request the 'email' scope. In most cases this is a mistake! " +
- "Call requestEmail() on the GoogleSignInOptions object.");
- }
-
- builder.requestIdToken(clientId);
- getParams().putParcelable(
- ExtraConstants.GOOGLE_SIGN_IN_OPTIONS, builder.build());
-
- return this;
- }
-
- @NonNull
- @Override
- public IdpConfig build() {
- if (!getParams().containsKey(ExtraConstants.GOOGLE_SIGN_IN_OPTIONS)) {
- validateWebClientId();
- setScopes(Collections.emptyList());
- }
-
- return super.build();
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Facebook provider.
- */
- public static final class FacebookBuilder extends Builder {
- private static final String TAG = "FacebookBuilder";
-
- public FacebookBuilder() {
- super(FacebookAuthProvider.PROVIDER_ID);
- if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) {
- throw new RuntimeException(
- "Facebook provider cannot be configured " +
- "without dependency. Did you forget to add " +
- "'com.facebook.android:facebook-login:VERSION' dependency?");
- }
- Preconditions.checkConfigured(getApplicationContext(),
- "Facebook provider unconfigured. Make sure to add a" +
- " `facebook_application_id` string. See the docs for more info:" +
- " https://github" +
- ".com/firebase/FirebaseUI-Android/blob/master/auth/README" +
- ".md#facebook",
- R.string.facebook_application_id);
- if (getApplicationContext().getString(R.string.facebook_login_protocol_scheme)
- .equals("fbYOUR_APP_ID")) {
- Log.w(TAG, "Facebook provider unconfigured for Chrome Custom Tabs.");
- }
- }
-
- /**
- * Specifies the additional permissions that the application will request in the
- * Facebook Login SDK. Available permissions can be found here.
- */
- @NonNull
- public FacebookBuilder setPermissions(@NonNull List permissions) {
- getParams().putStringArrayList(
- ExtraConstants.FACEBOOK_PERMISSIONS, new ArrayList<>(permissions));
- return this;
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Anonymous provider.
- */
- public static final class AnonymousBuilder extends Builder {
- public AnonymousBuilder() {
- super(ANONYMOUS_PROVIDER);
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Twitter provider.
- */
- public static final class TwitterBuilder extends GenericOAuthProviderBuilder {
- private static final String PROVIDER_NAME = "Twitter";
-
- public TwitterBuilder() {
- super(TwitterAuthProvider.PROVIDER_ID, PROVIDER_NAME,
- R.layout.fui_idp_button_twitter);
- }
- }
-
- /**
- * {@link IdpConfig} builder for the GitHub provider.
- */
- public static final class GitHubBuilder extends GenericOAuthProviderBuilder {
- private static final String PROVIDER_NAME = "Github";
-
- public GitHubBuilder() {
- super(GithubAuthProvider.PROVIDER_ID, PROVIDER_NAME,
- R.layout.fui_idp_button_github);
- }
-
- /**
- * Specifies the additional permissions to be requested.
- *
- *
Available permissions can be found
- * here.
- *
- * @deprecated Please use {@link #setScopes(List)} instead.
- */
- @Deprecated
- @NonNull
- public GitHubBuilder setPermissions(@NonNull List permissions) {
- setScopes(permissions);
- return this;
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Apple provider.
- */
- public static final class AppleBuilder extends GenericOAuthProviderBuilder {
- private static final String PROVIDER_NAME = "Apple";
-
- public AppleBuilder() {
- super(APPLE_PROVIDER, PROVIDER_NAME, R.layout.fui_idp_button_apple);
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Microsoft provider.
- */
- public static final class MicrosoftBuilder extends GenericOAuthProviderBuilder {
- private static final String PROVIDER_NAME = "Microsoft";
-
- public MicrosoftBuilder() {
- super(MICROSOFT_PROVIDER, PROVIDER_NAME, R.layout.fui_idp_button_microsoft);
- }
- }
-
- /**
- * {@link IdpConfig} builder for the Yahoo provider.
- */
- public static final class YahooBuilder extends GenericOAuthProviderBuilder {
- private static final String PROVIDER_NAME = "Yahoo";
-
- public YahooBuilder() {
- super(YAHOO_PROVIDER, PROVIDER_NAME, R.layout.fui_idp_button_yahoo);
- }
- }
-
- /**
- * {@link IdpConfig} builder for a Generic OAuth provider.
- */
- public static class GenericOAuthProviderBuilder extends Builder {
-
- public GenericOAuthProviderBuilder(@NonNull String providerId,
- @NonNull String providerName,
- int buttonId) {
- super(providerId);
-
- Preconditions.checkNotNull(providerId, "The provider ID cannot be null.");
- Preconditions.checkNotNull(providerName, "The provider name cannot be null.");
-
- getParams().putString(
- ExtraConstants.GENERIC_OAUTH_PROVIDER_ID, providerId);
- getParams().putString(
- ExtraConstants.GENERIC_OAUTH_PROVIDER_NAME, providerName);
- getParams().putInt(
- ExtraConstants.GENERIC_OAUTH_BUTTON_ID, buttonId);
-
- }
-
- @NonNull
- public GenericOAuthProviderBuilder setScopes(@NonNull List scopes) {
- getParams().putStringArrayList(
- ExtraConstants.GENERIC_OAUTH_SCOPES, new ArrayList<>(scopes));
- return this;
- }
-
- @NonNull
- public GenericOAuthProviderBuilder setCustomParameters(
- @NonNull Map customParameters) {
- getParams().putSerializable(
- ExtraConstants.GENERIC_OAUTH_CUSTOM_PARAMETERS,
- new HashMap<>(customParameters));
- return this;
- }
- }
- }
-
- /**
- * Base builder for both {@link SignInIntentBuilder}.
- */
- @SuppressWarnings(value = "unchecked")
- private abstract class AuthIntentBuilder {
- final List mProviders = new ArrayList<>();
- IdpConfig mDefaultProvider = null;
- int mLogo = NO_LOGO;
- int mTheme = getDefaultTheme();
- String mTosUrl;
- String mPrivacyPolicyUrl;
- boolean mAlwaysShowProviderChoice = false;
- boolean mLockOrientation = false;
- boolean mEnableCredentials = true;
- AuthMethodPickerLayout mAuthMethodPickerLayout = null;
- ActionCodeSettings mPasswordSettings = null;
-
- /**
- * Specifies the theme to use for the application flow. If no theme is specified, a
- * default theme will be used.
- */
- @NonNull
- public T setTheme(@StyleRes int theme) {
- mTheme = Preconditions.checkValidStyle(
- mApp.getApplicationContext(),
- theme,
- "theme identifier is unknown or not a style definition");
- return (T) this;
- }
-
- /**
- * Specifies the logo to use for the {@link AuthMethodPickerActivity}. If no logo is
- * specified, none will be used.
- */
- @NonNull
- public T setLogo(@DrawableRes int logo) {
- mLogo = logo;
- return (T) this;
- }
-
- /**
- * Specifies the terms-of-service URL for the application.
- *
- * @deprecated Please use {@link #setTosAndPrivacyPolicyUrls(String, String)} For the Tos
- * link to be displayed a Privacy Policy url must also be provided.
- */
- @NonNull
- @Deprecated
- public T setTosUrl(@Nullable String tosUrl) {
- mTosUrl = tosUrl;
- return (T) this;
- }
-
- /**
- * Specifies the privacy policy URL for the application.
- *
- * @deprecated Please use {@link #setTosAndPrivacyPolicyUrls(String, String)} For the
- * Privacy Policy link to be displayed a Tos url must also be provided.
- */
- @NonNull
- @Deprecated
- public T setPrivacyPolicyUrl(@Nullable String privacyPolicyUrl) {
- mPrivacyPolicyUrl = privacyPolicyUrl;
- return (T) this;
- }
-
- @NonNull
- public T setTosAndPrivacyPolicyUrls(@NonNull String tosUrl,
- @NonNull String privacyPolicyUrl) {
- Preconditions.checkNotNull(tosUrl, "tosUrl cannot be null");
- Preconditions.checkNotNull(privacyPolicyUrl, "privacyPolicyUrl cannot be null");
- mTosUrl = tosUrl;
- mPrivacyPolicyUrl = privacyPolicyUrl;
- return (T) this;
- }
-
- /**
- * Specifies the set of supported authentication providers. At least one provider must
- * be specified. There may only be one instance of each provider. Anonymous provider cannot
- * be the only provider specified.
- *
- *
If no providers are explicitly specified by calling this method, then the email
- * provider is the default supported provider.
- *
- * @param idpConfigs a list of {@link IdpConfig}s, where each {@link IdpConfig} contains the
- * configuration parameters for the IDP.
- * @throws IllegalStateException if anonymous provider is the only specified provider.
- * @see IdpConfig
- */
- @NonNull
- public T setAvailableProviders(@NonNull List idpConfigs) {
- Preconditions.checkNotNull(idpConfigs, "idpConfigs cannot be null");
- if (idpConfigs.size() == 1 &&
- idpConfigs.get(0).getProviderId().equals(ANONYMOUS_PROVIDER)) {
- throw new IllegalStateException("Sign in as guest cannot be the only sign in " +
- "method. In this case, sign the user in anonymously your self; " +
- "no UI is needed.");
- }
-
- mProviders.clear();
-
- for (IdpConfig config : idpConfigs) {
- if (mProviders.contains(config)) {
- throw new IllegalArgumentException("Each provider can only be set once. "
- + config.getProviderId()
- + " was set twice.");
- } else {
- mProviders.add(config);
- }
- }
-
- return (T) this;
- }
-
- /**
- * Specifies the default authentication provider, bypassing the provider selection screen.
- * The provider here must already be included via {@link #setAvailableProviders(List)}, and
- * this method is incompatible with {@link #setAlwaysShowSignInMethodScreen(boolean)}.
- *
- * @param config the default {@link IdpConfig} to use.
- */
- @NonNull
- public T setDefaultProvider(@Nullable IdpConfig config) {
- if (config != null) {
- if (!mProviders.contains(config)) {
- throw new IllegalStateException(
- "Default provider not in available providers list.");
- }
- if (mAlwaysShowProviderChoice) {
- throw new IllegalStateException(
- "Can't set default provider and always show provider choice.");
- }
- }
- mDefaultProvider = config;
- return (T) this;
- }
-
- /**
- * Enables or disables the use of Credential Manager for Passwords credential selector
- *
- *
Is enabled by default.
- *
- * @param enableCredentials enables credential selector before signup
- */
- @NonNull
- public T setCredentialManagerEnabled(boolean enableCredentials) {
- mEnableCredentials = enableCredentials;
- return (T) this;
- }
-
- /**
- * Set a custom layout for the AuthMethodPickerActivity screen.
- * See {@link AuthMethodPickerLayout}.
- *
- * @param authMethodPickerLayout custom layout descriptor object.
- */
- @NonNull
- public T setAuthMethodPickerLayout(@NonNull AuthMethodPickerLayout authMethodPickerLayout) {
- mAuthMethodPickerLayout = authMethodPickerLayout;
- return (T) this;
- }
-
- /**
- * Forces the sign-in method choice screen to always show, even if there is only
- * a single provider configured.
- *
- *
This is false by default.
- *
- * @param alwaysShow if true, force the sign-in choice screen to show.
- */
- @NonNull
- public T setAlwaysShowSignInMethodScreen(boolean alwaysShow) {
- if (alwaysShow && mDefaultProvider != null) {
- throw new IllegalStateException(
- "Can't show provider choice with a default provider.");
- }
- mAlwaysShowProviderChoice = alwaysShow;
- return (T) this;
- }
-
- /**
- * Enable or disables the orientation for small devices to be locked in
- * Portrait orientation
- *
- *
This is false by default.
- *
- * @param lockOrientation if true, force the activities to be in Portrait orientation.
- */
- @NonNull
- public T setLockOrientation(boolean lockOrientation) {
- mLockOrientation = lockOrientation;
- return (T) this;
- }
-
- /**
- * Set custom settings for the RecoverPasswordActivity.
- *
- * @param passwordSettings to allow additional state via a continue URL.
- */
- @NonNull
- public T setResetPasswordSettings(ActionCodeSettings passwordSettings) {
- mPasswordSettings = passwordSettings;
- return (T) this;
- }
-
- @CallSuper
- @NonNull
- public Intent build() {
- if (mProviders.isEmpty()) {
- mProviders.add(new IdpConfig.EmailBuilder().build());
- }
-
- return KickoffActivity.createIntent(mApp.getApplicationContext(), getFlowParams());
- }
-
- protected abstract FlowParameters getFlowParams();
- }
-
- /**
- * Builder for the intent to start the user authentication flow.
- */
- public final class SignInIntentBuilder extends AuthIntentBuilder {
-
- private String mEmailLink;
- private boolean mEnableAnonymousUpgrade;
-
- private SignInIntentBuilder() {
- super();
- }
-
- /**
- * Specifies the email link to be used for sign in. When set, a sign in attempt will be
- * made immediately.
- */
- @NonNull
- public SignInIntentBuilder setEmailLink(@NonNull final String emailLink) {
- mEmailLink = emailLink;
- return this;
- }
-
- /**
- * Enables upgrading anonymous accounts to full accounts during the sign-in flow.
- * This is disabled by default.
- *
- * @throws IllegalStateException when you attempt to enable anonymous user upgrade
- * without forcing the same device flow for email link sign in.
- */
- @NonNull
- public SignInIntentBuilder enableAnonymousUsersAutoUpgrade() {
- mEnableAnonymousUpgrade = true;
- validateEmailBuilderConfig();
- return this;
- }
-
- private void validateEmailBuilderConfig() {
- for (int i = 0; i < mProviders.size(); i++) {
- IdpConfig config = mProviders.get(i);
- if (config.getProviderId().equals(EMAIL_LINK_PROVIDER)) {
- boolean emailLinkForceSameDevice =
- config.getParams().getBoolean(ExtraConstants.FORCE_SAME_DEVICE, true);
- if (!emailLinkForceSameDevice) {
- throw new IllegalStateException("You must force the same device flow " +
- "when using email link sign in with anonymous user upgrade");
- }
- }
- }
- }
-
- @Override
- protected FlowParameters getFlowParams() {
- return new FlowParameters(
- mApp.getName(),
- mProviders,
- mDefaultProvider,
- mTheme,
- mLogo,
- mTosUrl,
- mPrivacyPolicyUrl,
- mEnableCredentials,
- mEnableAnonymousUpgrade,
- mAlwaysShowProviderChoice,
- mLockOrientation,
- mEmailLink,
- mPasswordSettings,
- mAuthMethodPickerLayout);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ErrorCodes.java b/auth/src/main/java/com/firebase/ui/auth/ErrorCodes.java
deleted file mode 100644
index a39869d6e..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ErrorCodes.java
+++ /dev/null
@@ -1,144 +0,0 @@
-package com.firebase.ui.auth;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-/**
- * Error codes for failed sign-in attempts.
- */
-public final class ErrorCodes {
- /**
- * An unknown error has occurred.
- */
- public static final int UNKNOWN_ERROR = 0;
- /**
- * Sign in failed due to lack of network connection.
- */
- public static final int NO_NETWORK = 1;
- /**
- * A required update to Play Services was cancelled by the user.
- */
- public static final int PLAY_SERVICES_UPDATE_CANCELLED = 2;
- /**
- * A sign-in operation couldn't be completed due to a developer error.
- */
- public static final int DEVELOPER_ERROR = 3;
- /**
- * An external sign-in provider error occurred.
- */
- public static final int PROVIDER_ERROR = 4;
- /**
- * Anonymous account linking failed.
- */
- public static final int ANONYMOUS_UPGRADE_MERGE_CONFLICT = 5;
- /**
- * Signing in with a different email in the WelcomeBackIdp flow or email link flow.
- */
- public static final int EMAIL_MISMATCH_ERROR = 6;
- /**
- * Attempting to sign in with an invalid email link.
- */
- public static final int INVALID_EMAIL_LINK_ERROR = 7;
-
- /**
- * Attempting to open an email link from a different device.
- */
- public static final int EMAIL_LINK_WRONG_DEVICE_ERROR = 8;
-
- /**
- * We need to prompt the user for their email.
- * */
- public static final int EMAIL_LINK_PROMPT_FOR_EMAIL_ERROR = 9;
-
- /**
- * Cross device linking flow - we need to ask the user if they want to continue linking or
- * just sign in.
- * */
- public static final int EMAIL_LINK_CROSS_DEVICE_LINKING_ERROR = 10;
-
- /**
- * Attempting to open an email link from the same device, with anonymous upgrade enabled,
- * but the underlying anonymous user has been changed.
- */
- public static final int EMAIL_LINK_DIFFERENT_ANONYMOUS_USER_ERROR = 11;
-
- /**
- * Attempting to auth with account that is currently disabled in the Firebase console.
- */
- public static final int ERROR_USER_DISABLED = 12;
-
- /**
- * Recoverable error occurred during the Generic IDP flow.
- */
- public static final int ERROR_GENERIC_IDP_RECOVERABLE_ERROR = 13;
-
- private ErrorCodes() {
- throw new AssertionError("No instance for you!");
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static String toFriendlyMessage(@Code int code) {
- switch (code) {
- case UNKNOWN_ERROR:
- return "Unknown error";
- case NO_NETWORK:
- return "No internet connection";
- case PLAY_SERVICES_UPDATE_CANCELLED:
- return "Play Services update cancelled";
- case DEVELOPER_ERROR:
- return "Developer error";
- case PROVIDER_ERROR:
- return "Provider error";
- case ANONYMOUS_UPGRADE_MERGE_CONFLICT:
- return "User account merge conflict";
- case EMAIL_MISMATCH_ERROR:
- return "You are are attempting to sign in a different email than previously " +
- "provided";
- case INVALID_EMAIL_LINK_ERROR:
- return "You are are attempting to sign in with an invalid email link";
- case EMAIL_LINK_PROMPT_FOR_EMAIL_ERROR:
- return "Please enter your email to continue signing in";
- case EMAIL_LINK_WRONG_DEVICE_ERROR:
- return "You must open the email link on the same device.";
- case EMAIL_LINK_CROSS_DEVICE_LINKING_ERROR:
- return "You must determine if you want to continue linking or complete the sign in";
- case EMAIL_LINK_DIFFERENT_ANONYMOUS_USER_ERROR:
- return "The session associated with this sign-in request has either expired or " +
- "was cleared";
- case ERROR_USER_DISABLED:
- return "The user account has been disabled by an administrator.";
- case ERROR_GENERIC_IDP_RECOVERABLE_ERROR:
- return "Generic IDP recoverable error.";
- default:
- throw new IllegalArgumentException("Unknown code: " + code);
- }
- }
-
- /**
- * Valid codes that can be returned from {@link FirebaseUiException#getErrorCode()}.
- */
- @IntDef({
- UNKNOWN_ERROR,
- NO_NETWORK,
- PLAY_SERVICES_UPDATE_CANCELLED,
- DEVELOPER_ERROR,
- PROVIDER_ERROR,
- ANONYMOUS_UPGRADE_MERGE_CONFLICT,
- EMAIL_MISMATCH_ERROR,
- INVALID_EMAIL_LINK_ERROR,
- EMAIL_LINK_WRONG_DEVICE_ERROR,
- EMAIL_LINK_PROMPT_FOR_EMAIL_ERROR,
- EMAIL_LINK_CROSS_DEVICE_LINKING_ERROR,
- EMAIL_LINK_DIFFERENT_ANONYMOUS_USER_ERROR,
- ERROR_USER_DISABLED,
- ERROR_GENERIC_IDP_RECOVERABLE_ERROR
- })
- @Retention(RetentionPolicy.SOURCE)
- public @interface Code {
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
new file mode 100644
index 000000000..af71920d7
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthActivity.kt
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.lifecycle.lifecycleScope
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen
+import com.firebase.ui.auth.util.EmailLinkConstants
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Activity that hosts the Firebase authentication flow UI.
+ *
+ * This activity displays the [FirebaseAuthScreen] composable and manages
+ * the authentication flow lifecycle. It automatically finishes when the user
+ * signs in successfully or cancels the flow.
+ *
+ * **Do not launch this Activity directly.**
+ * Use [AuthFlowController] to start the auth flow:
+ *
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...))
+ * }
+ * val controller = authUI.createAuthFlow(configuration)
+ * val intent = controller.createIntent(context)
+ * launcher.launch(intent)
+ * ```
+ *
+ * **Result Codes:**
+ * - [Activity.RESULT_OK] - User signed in successfully
+ * - [Activity.RESULT_CANCELED] - User cancelled or error occurred
+ *
+ * **Result Data:**
+ * - [EXTRA_USER_ID] - User ID string (when RESULT_OK)
+ * - [EXTRA_IS_NEW_USER] - Boolean indicating if user is new (when RESULT_OK)
+ * - [EXTRA_ERROR] - [AuthException] when an error occurs
+ *
+ * **Note:** To get the full user object after successful sign-in, use:
+ * ```kotlin
+ * FirebaseAuth.getInstance().currentUser
+ * ```
+ *
+ * @see AuthFlowController
+ * @see FirebaseAuthScreen
+ * @since 10.0.0
+ */
+class FirebaseAuthActivity : ComponentActivity() {
+
+ private lateinit var authUI: FirebaseAuthUI
+ private lateinit var configuration: AuthUIConfiguration
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Extract configuration from cache using UUID key
+ val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY)
+ configuration = if (configKey != null) {
+ configurationCache.remove(configKey)
+ } else {
+ null
+ } ?: run {
+ // Missing configuration, finish with error
+ setResult(RESULT_CANCELED)
+ finish()
+ return
+ }
+
+ authUI = FirebaseAuthUI.getInstance()
+
+ // Extract email link if present
+ val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK)
+
+ // Observe auth state to automatically finish when done
+ lifecycleScope.launch {
+ authUI.authStateFlow().collect { state ->
+ when (state) {
+ is AuthState.Success -> {
+ // User signed in successfully
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_USER_ID, state.user.uid)
+ putExtra(EXTRA_IS_NEW_USER, state.isNewUser)
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
+ is AuthState.Cancelled -> {
+ // User cancelled the flow
+ setResult(RESULT_CANCELED)
+ finish()
+ }
+ is AuthState.Error -> {
+ // Error occurred, finish with error info
+ val resultIntent = Intent().apply {
+ putExtra(EXTRA_ERROR, state.exception)
+ }
+ setResult(RESULT_CANCELED, resultIntent)
+ // Don't finish on error, let user see error and retry
+ }
+ else -> {
+ // Other states, keep showing UI
+ }
+ }
+ }
+ }
+
+ // Set up Compose UI
+ setContent {
+ AuthUITheme(theme = configuration.theme) {
+ FirebaseAuthScreen(
+ authUI = authUI,
+ configuration = configuration,
+ emailLink = emailLink,
+ onSignInSuccess = { authResult ->
+ // State flow will handle finishing
+ },
+ onSignInFailure = { exception ->
+ // State flow will handle error
+ },
+ onSignInCancelled = {
+ authUI.updateAuthState(AuthState.Cancelled)
+ }
+ )
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Reset auth state when activity is destroyed
+ if (!isFinishing) {
+ authUI.updateAuthState(AuthState.Idle)
+ }
+ }
+
+ companion object {
+ private const val EXTRA_CONFIGURATION_KEY = "com.firebase.ui.auth.CONFIGURATION_KEY"
+
+ /**
+ * Intent extra key for user ID on successful sign-in.
+ * Use [com.google.firebase.auth.FirebaseAuth.getInstance().currentUser] to get the full user object.
+ */
+ const val EXTRA_USER_ID = "com.firebase.ui.auth.USER_ID"
+
+ /**
+ * Intent extra key for isNewUser flag on successful sign-in.
+ */
+ const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.IS_NEW_USER"
+
+ /**
+ * Intent extra key for [AuthException] on error.
+ */
+ const val EXTRA_ERROR = "com.firebase.ui.auth.ERROR"
+
+ /**
+ * Cache for configurations passed through Intents.
+ * Uses UUID keys to avoid serialization issues with Context references.
+ */
+ private val configurationCache = ConcurrentHashMap()
+
+ /**
+ * Creates an Intent to launch the Firebase authentication flow.
+ *
+ * @param context Android [Context]
+ * @param configuration [AuthUIConfiguration] defining the auth flow
+ * @return Configured [Intent] to start [FirebaseAuthActivity]
+ */
+ internal fun createIntent(
+ context: Context,
+ configuration: AuthUIConfiguration
+ ): Intent {
+ val configKey = UUID.randomUUID().toString()
+ configurationCache[configKey] = configuration
+
+ return Intent(context, FirebaseAuthActivity::class.java).apply {
+ putExtra(EXTRA_CONFIGURATION_KEY, configKey)
+ }
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthAnonymousUpgradeException.java b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthAnonymousUpgradeException.java
deleted file mode 100644
index a5139261b..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthAnonymousUpgradeException.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.firebase.ui.auth;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class FirebaseAuthAnonymousUpgradeException extends Exception {
-
- private IdpResponse mResponse;
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseAuthAnonymousUpgradeException(@ErrorCodes.Code int code,
- @NonNull IdpResponse response) {
- super(ErrorCodes.toFriendlyMessage(code));
- mResponse = response;
- }
-
- public IdpResponse getResponse() {
- return mResponse;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
new file mode 100644
index 000000000..df5a49173
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUI.kt
@@ -0,0 +1,579 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth
+
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.RestrictTo
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseAuth.AuthStateListener
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.ktx.auth
+import com.google.firebase.ktx.Firebase
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.tasks.await
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * The central class that coordinates all authentication operations for Firebase Auth UI Compose.
+ * This class manages UI state and provides methods for signing in, signing up, and managing
+ * user accounts.
+ *
+ *
Usage
+ *
+ * **Default app instance:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * ```
+ *
+ * **Custom app instance:**
+ * ```kotlin
+ * val customApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(customApp)
+ * ```
+ *
+ * **Multi-tenancy with custom auth:**
+ * ```kotlin
+ * val customAuth = Firebase.auth(customApp).apply {
+ * tenantId = "my-tenant-id"
+ * }
+ * val authUI = FirebaseAuthUI.create(customApp, customAuth)
+ * ```
+ *
+ * @property app The [FirebaseApp] instance used for authentication
+ * @property auth The [FirebaseAuth] instance used for authentication operations
+ *
+ * @since 10.0.0
+ */
+class FirebaseAuthUI private constructor(
+ val app: FirebaseApp,
+ val auth: FirebaseAuth,
+) {
+
+ private val _authStateFlow = MutableStateFlow(AuthState.Idle)
+
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null
+
+ /**
+ * Checks whether a user is currently signed in.
+ *
+ * This method directly mirrors the state of [FirebaseAuth] and returns true if there is
+ * a currently signed-in user, false otherwise.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * if (authUI.isSignedIn()) {
+ * // User is signed in
+ * navigateToHome()
+ * } else {
+ * // User is not signed in
+ * navigateToLogin()
+ * }
+ * ```
+ *
+ * @return `true` if a user is signed in, `false` otherwise
+ */
+ fun isSignedIn(): Boolean = auth.currentUser != null
+
+ /**
+ * Returns the currently signed-in user, or null if no user is signed in.
+ *
+ * This method returns the same value as [FirebaseAuth.currentUser] and provides
+ * direct access to the current user object.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val user = authUI.getCurrentUser()
+ * user?.let {
+ * println("User email: ${it.email}")
+ * println("User ID: ${it.uid}")
+ * }
+ * ```
+ *
+ * @return The currently signed-in [FirebaseUser], or `null` if no user is signed in
+ */
+ fun getCurrentUser(): FirebaseUser? = auth.currentUser
+
+ /**
+ * Returns true if this instance can handle the provided [Intent].
+ *
+ * This mirrors the classic `AuthUI.canHandleIntent` API but uses the [FirebaseAuth] instance
+ * backing this [FirebaseAuthUI], ensuring custom app/auth configurations are respected.
+ */
+ fun canHandleIntent(intent: Intent?): Boolean {
+ val link = intent?.data ?: return false
+ return auth.isSignInWithEmailLink(link.toString())
+ }
+
+ /**
+ * Creates a new authentication flow controller with the specified configuration.
+ *
+ * This method returns an [AuthFlowController] that manages the authentication flow
+ * lifecycle. The controller provides methods to start the flow, monitor its state,
+ * and clean up resources when done.
+ *
+ * **Example with ActivityResultLauncher:**
+ * ```kotlin
+ * class MyActivity : ComponentActivity() {
+ * private lateinit var authController: AuthFlowController
+ *
+ * private val authLauncher = registerForActivityResult(
+ * ActivityResultContracts.StartActivityForResult()
+ * ) { result ->
+ * if (result.resultCode == Activity.RESULT_OK) {
+ * val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)
+ * val isNewUser = result.data?.getBooleanExtra(
+ * FirebaseAuthActivity.EXTRA_IS_NEW_USER,
+ * false
+ * ) ?: false
+ * // Get the full user object
+ * val user = FirebaseAuth.getInstance().currentUser
+ * }
+ * }
+ *
+ * override fun onCreate(savedInstanceState: Bundle?) {
+ * super.onCreate(savedInstanceState)
+ *
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val configuration = authUIConfiguration {
+ * providers = listOf(
+ * AuthProvider.Email(),
+ * AuthProvider.Google(...)
+ * )
+ * }
+ *
+ * authController = authUI.createAuthFlow(configuration)
+ *
+ * // Observe auth state
+ * lifecycleScope.launch {
+ * authController.authStateFlow.collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User signed in successfully
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * }
+ * else -> {}
+ * }
+ * }
+ * }
+ *
+ * // Start auth flow
+ * val intent = authController.createIntent(this)
+ * authLauncher.launch(intent)
+ * }
+ *
+ * override fun onDestroy() {
+ * super.onDestroy()
+ * authController.dispose()
+ * }
+ * }
+ * ```
+ *
+ * @param configuration The [AuthUIConfiguration] defining the auth flow behavior
+ * @return A new [AuthFlowController] instance
+ * @see AuthFlowController
+ * @since 10.0.0
+ */
+ fun createAuthFlow(configuration: AuthUIConfiguration): AuthFlowController {
+ return AuthFlowController(this, configuration)
+ }
+
+ /**
+ * Returns a [Flow] that emits [AuthState] changes.
+ *
+ * This flow observes changes to the authentication state and emits appropriate
+ * [AuthState] objects. The flow will emit:
+ * - [AuthState.Idle] when there's no active authentication operation
+ * - [AuthState.Loading] during authentication operations
+ * - [AuthState.Success] when a user successfully signs in
+ * - [AuthState.Error] when an authentication error occurs
+ * - [AuthState.Cancelled] when authentication is cancelled
+ * - [AuthState.RequiresMfa] when multi-factor authentication is needed
+ * - [AuthState.RequiresEmailVerification] when email verification is needed
+ *
+ * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on
+ * the current authentication state when collection starts.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * lifecycleScope.launch {
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.Success -> {
+ * // User is signed in
+ * updateUI(state.user)
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * showError(state.exception.message)
+ * }
+ * is AuthState.Loading -> {
+ * // Show loading indicator
+ * showProgressBar()
+ * }
+ * // ... handle other states
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @return A [Flow] of [AuthState] that emits authentication state changes
+ */
+ fun authStateFlow(): Flow {
+ // Create a flow from FirebaseAuth state listener
+ val firebaseAuthFlow = callbackFlow {
+ // Set initial state based on current auth state
+ val initialState = auth.currentUser?.let { user ->
+ // Check if email verification is required
+ if (!user.isEmailVerified &&
+ user.email != null &&
+ user.providerData.any { it.providerId == "password" }
+ ) {
+ AuthState.RequiresEmailVerification(
+ user = user,
+ email = user.email!!
+ )
+ } else {
+ AuthState.Success(result = null, user = user, isNewUser = false)
+ }
+ } ?: AuthState.Idle
+
+ trySend(initialState)
+
+ // Create auth state listener
+ val authStateListener = AuthStateListener { firebaseAuth ->
+ val currentUser = firebaseAuth.currentUser
+ val state = if (currentUser != null) {
+ // Check if email verification is required
+ if (!currentUser.isEmailVerified &&
+ currentUser.email != null &&
+ currentUser.providerData.any { it.providerId == "password" }
+ ) {
+ AuthState.RequiresEmailVerification(
+ user = currentUser,
+ email = currentUser.email!!
+ )
+ } else {
+ AuthState.Success(
+ result = null,
+ user = currentUser,
+ isNewUser = false
+ )
+ }
+ } else {
+ AuthState.Idle
+ }
+ trySend(state)
+ }
+
+ // Add listener
+ auth.addAuthStateListener(authStateListener)
+
+ // Remove listener when flow collection is cancelled
+ awaitClose {
+ auth.removeAuthStateListener(authStateListener)
+ }
+ }
+
+ // Also observe internal state changes
+ return combine(
+ firebaseAuthFlow,
+ _authStateFlow
+ ) { firebaseState, internalState ->
+ // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.)
+ if (internalState !is AuthState.Idle) internalState else firebaseState
+ }.distinctUntilChanged()
+ }
+
+ /**
+ * Updates the internal authentication state.
+ * This method can be used to manually trigger state updates when the Firebase Auth state
+ * listener doesn't automatically detect changes (e.g., after reloading user properties).
+ *
+ * @param state The new [AuthState] to emit
+ */
+ fun updateAuthState(state: AuthState) {
+ _authStateFlow.value = state
+ }
+
+ /**
+ * Signs out the current user and clears authentication state.
+ *
+ * This method signs out the user from Firebase Auth and updates the auth state flow
+ * to reflect the change. The operation is performed asynchronously and will emit
+ * appropriate states during the process.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * try {
+ * authUI.signOut(context)
+ * // User is now signed out
+ * } catch (e: AuthException) {
+ * // Handle sign-out error
+ * when (e) {
+ * is AuthException.AuthCancelledException -> {
+ * // User cancelled sign-out
+ * }
+ * else -> {
+ * // Other error occurred
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @param context The Android [Context] for any required UI operations
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ * @since 10.0.0
+ */
+ suspend fun signOut(context: Context) {
+ try {
+ // Update state to loading
+ updateAuthState(AuthState.Loading("Signing out..."))
+
+ // Sign out from Firebase Auth
+ auth.signOut()
+ .also {
+ signOutFromGoogle(context)
+ }
+
+ // Update state to idle (user signed out)
+ updateAuthState(AuthState.Idle)
+
+ } catch (e: CancellationException) {
+ // Handle coroutine cancellation
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign-out was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ // Already mapped AuthException, just update state and re-throw
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ // Map to appropriate AuthException
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+ }
+
+ /**
+ * Deletes the current user account and clears authentication state.
+ *
+ * This method deletes the current user's account from Firebase Auth. If the user
+ * hasn't signed in recently, it will throw an exception requiring reauthentication.
+ * The operation is performed asynchronously and will emit appropriate states during
+ * the process.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ *
+ * try {
+ * authUI.delete(context)
+ * // User account is now deleted
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Recent login required - show reauthentication UI
+ * handleReauthentication()
+ * } catch (e: AuthException) {
+ * // Handle other errors
+ * }
+ * ```
+ *
+ * @param context The Android [Context] for any required UI operations
+ * @throws AuthException.InvalidCredentialsException if reauthentication is required
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ * @since 10.0.0
+ */
+ suspend fun delete(context: Context) {
+ try {
+ val currentUser = auth.currentUser
+ ?: throw AuthException.UserNotFoundException(
+ message = "No user is currently signed in"
+ )
+
+ // Update state to loading
+ updateAuthState(AuthState.Loading("Deleting account..."))
+
+ // Delete the user account
+ currentUser.delete().await()
+
+ // Update state to idle (user deleted and signed out)
+ updateAuthState(AuthState.Idle)
+
+ } catch (e: CancellationException) {
+ // Handle coroutine cancellation
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Account deletion was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ // Already mapped AuthException, just update state and re-throw
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ // Map to appropriate AuthException
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+ }
+
+ companion object {
+ /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */
+ private val instanceCache = ConcurrentHashMap()
+
+ /** Special key for the default app instance to distinguish from named instances. */
+ private const val DEFAULT_APP_KEY = "__FIREBASE_UI_DEFAULT__"
+
+ /**
+ * Returns a cached singleton instance for the default Firebase app.
+ *
+ * This method ensures that the same instance is returned for the default app across the
+ * entire application lifecycle. The instance is lazily created on first access and cached
+ * for subsequent calls.
+ *
+ * **Example:**
+ * ```kotlin
+ * val authUI = FirebaseAuthUI.getInstance()
+ * val user = authUI.auth.currentUser
+ * ```
+ *
+ * @return The cached [FirebaseAuthUI] instance for the default app
+ * @throws IllegalStateException if Firebase has not been initialized. Call
+ * `FirebaseApp.initializeApp(Context)` before using this method.
+ */
+ @JvmStatic
+ fun getInstance(): FirebaseAuthUI {
+ val defaultApp = try {
+ FirebaseApp.getInstance()
+ } catch (e: IllegalStateException) {
+ throw IllegalStateException(
+ "Default FirebaseApp is not initialized. " +
+ "Make sure to call FirebaseApp.initializeApp(Context) first.",
+ e
+ )
+ }
+
+ return instanceCache.getOrPut(DEFAULT_APP_KEY) {
+ FirebaseAuthUI(defaultApp, Firebase.auth)
+ }
+ }
+
+ /**
+ * Returns a cached instance for a specific Firebase app.
+ *
+ * Each [FirebaseApp] gets its own distinct instance that is cached for subsequent calls
+ * with the same app. This allows for multiple Firebase projects to be used within the
+ * same application.
+ *
+ * **Example:**
+ * ```kotlin
+ * val secondaryApp = Firebase.app("secondary")
+ * val authUI = FirebaseAuthUI.getInstance(secondaryApp)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @return The cached [FirebaseAuthUI] instance for the specified app
+ */
+ @JvmStatic
+ fun getInstance(app: FirebaseApp): FirebaseAuthUI {
+ val cacheKey = app.name
+ return instanceCache.getOrPut(cacheKey) {
+ FirebaseAuthUI(app, Firebase.auth(app))
+ }
+ }
+
+ /**
+ * Creates a new instance with custom configuration, useful for multi-tenancy.
+ *
+ * This method always returns a new instance and does **not** use caching, allowing for
+ * custom [FirebaseAuth] configurations such as tenant IDs or custom authentication states.
+ * Use this when you need fine-grained control over the authentication instance.
+ *
+ * **Example - Multi-tenancy:**
+ * ```kotlin
+ * val app = Firebase.app("tenant-app")
+ * val auth = Firebase.auth(app).apply {
+ * tenantId = "customer-tenant-123"
+ * }
+ * val authUI = FirebaseAuthUI.create(app, auth)
+ * ```
+ *
+ * @param app The [FirebaseApp] instance to use
+ * @param auth The [FirebaseAuth] instance with custom configuration
+ * @return A new [FirebaseAuthUI] instance with the provided dependencies
+ */
+ @JvmStatic
+ fun create(app: FirebaseApp, auth: FirebaseAuth): FirebaseAuthUI {
+ return FirebaseAuthUI(app, auth)
+ }
+
+ /**
+ * Clears all cached instances. This method is intended for testing purposes only.
+ *
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ fun clearInstanceCache() {
+ instanceCache.clear()
+ }
+
+ /**
+ * Returns the current number of cached instances. This method is intended for testing
+ * purposes only.
+ *
+ * @return The number of cached [FirebaseAuthUI] instances
+ * @suppress This is an internal API and should not be used in production code.
+ * @RestrictTo RestrictTo.Scope.TESTS
+ */
+ @JvmStatic
+ @RestrictTo(RestrictTo.Scope.TESTS)
+ internal fun getCacheSize(): Int {
+ return instanceCache.size
+ }
+
+ const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME"
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUIActivityResultContract.java b/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUIActivityResultContract.java
deleted file mode 100644
index 1edbd3a8e..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseAuthUIActivityResultContract.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.firebase.ui.auth;
-
-import android.content.Context;
-import android.content.Intent;
-
-import com.firebase.ui.auth.data.model.FirebaseAuthUIAuthenticationResult;
-
-import androidx.activity.result.contract.ActivityResultContract;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-/**
- * A {@link ActivityResultContract} describing that the caller can launch authentication flow with a
- * {@link Intent} and is guaranteed to receive a {@link FirebaseAuthUIAuthenticationResult} as
- * result. The given input intent must be created using a
- * {@link com.firebase.ui.auth.AuthUI.SignInIntentBuilder} in order to guarantee a successful
- * launch of the authentication flow.
- */
-public class FirebaseAuthUIActivityResultContract extends
- ActivityResultContract {
-
- @NonNull
- @Override
- public Intent createIntent(@NonNull Context context, Intent input) {
- return input;
- }
-
- @Override
- @NonNull
- public FirebaseAuthUIAuthenticationResult parseResult(int resultCode, @Nullable Intent intent) {
- return new FirebaseAuthUIAuthenticationResult(resultCode, IdpResponse.fromResultIntent(intent));
- }
-
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt b/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt
new file mode 100644
index 000000000..c985a29af
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/FirebaseUIComposeRegistrar.kt
@@ -0,0 +1,37 @@
+// Copyright 2025 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.firebase.ui.auth
+
+import android.util.Log
+import androidx.annotation.Keep
+import com.google.firebase.components.Component
+import com.google.firebase.components.ComponentRegistrar
+import com.google.firebase.platforminfo.LibraryVersionComponent
+
+/**
+ * Registers the FirebaseUI-Android Compose library with Firebase Analytics.
+ * This enables Firebase to track which versions of FirebaseUI are being used.
+ */
+@Keep
+class FirebaseUIComposeRegistrar : ComponentRegistrar {
+ override fun getComponents(): List> {
+ Log.d("FirebaseUIRegistrar", "FirebaseUI Compose Registrar initialized: " +
+ "LIBRARY_NAME: ${BuildConfig.LIBRARY_NAME}, " +
+ "VERSION_NAME: ${BuildConfig.VERSION_NAME}")
+ return listOf(
+ LibraryVersionComponent.create(BuildConfig.LIBRARY_NAME, BuildConfig.VERSION_NAME)
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseUiException.java b/auth/src/main/java/com/firebase/ui/auth/FirebaseUiException.java
deleted file mode 100644
index 2578adad3..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseUiException.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.firebase.ui.auth;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-/**
- * Base class for all FirebaseUI exceptions.
- */
-public class FirebaseUiException extends Exception {
- private final int mErrorCode;
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseUiException(@ErrorCodes.Code int code) {
- this(code, ErrorCodes.toFriendlyMessage(code));
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseUiException(@ErrorCodes.Code int code, @NonNull String message) {
- super(message);
- mErrorCode = code;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseUiException(@ErrorCodes.Code int code, @NonNull Throwable cause) {
- this(code, ErrorCodes.toFriendlyMessage(code), cause);
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseUiException(@ErrorCodes.Code int code,
- @NonNull String message,
- @NonNull Throwable cause) {
- super(message, cause);
- mErrorCode = code;
- }
-
- /**
- * @return error code associated with this exception
- * @see com.firebase.ui.auth.ErrorCodes
- */
- @ErrorCodes.Code
- public final int getErrorCode() {
- return mErrorCode;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/FirebaseUiUserCollisionException.java b/auth/src/main/java/com/firebase/ui/auth/FirebaseUiUserCollisionException.java
deleted file mode 100644
index 41511f51f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/FirebaseUiUserCollisionException.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.firebase.ui.auth;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-import com.google.firebase.auth.AuthCredential;
-
-/**
- * Internal exception which holds the necessary data to complete sign-in in the event of a
- * recoverable error.
- */
-public class FirebaseUiUserCollisionException extends Exception {
-
- private final int mErrorCode;
- private final String mProviderId;
- private final String mEmail;
- private final AuthCredential mCredential;
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public FirebaseUiUserCollisionException(@ErrorCodes.Code int code,
- @NonNull String message,
- @NonNull String providerId,
- @NonNull String email,
- @NonNull AuthCredential credential) {
- super(message);
- mErrorCode = code;
- mProviderId = providerId;
- mEmail = email;
- mCredential = credential;
- }
-
- @NonNull
- public String getProviderId() {
- return mProviderId;
- }
-
- @NonNull
- public String getEmail() {
- return mEmail;
- }
-
- @NonNull
- public AuthCredential getCredential() {
- return mCredential;
- }
-
- /**
- * @return error code associated with this exception
- * @see ErrorCodes
- */
- @ErrorCodes.Code
- public final int getErrorCode() {
- return mErrorCode;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java b/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java
deleted file mode 100644
index 337188acc..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/IdpResponse.java
+++ /dev/null
@@ -1,401 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth;
-
-import android.annotation.SuppressLint;
-import android.content.Intent;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.text.TextUtils;
-
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.GoogleAuthProvider;
-import com.google.firebase.auth.TwitterAuthProvider;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.ObjectOutputStream;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-/**
- * A container that encapsulates the result of authenticating with an Identity Provider.
- */
-public class IdpResponse implements Parcelable {
- public static final Creator CREATOR = new Creator() {
- @Override
- public IdpResponse createFromParcel(Parcel in) {
- return new IdpResponse(
- in.readParcelable(User.class.getClassLoader()),
- in.readString(),
- in.readString(),
- in.readInt() == 1,
- (FirebaseUiException) in.readSerializable(),
- in.readParcelable(AuthCredential.class.getClassLoader())
- );
- }
-
- @Override
- public IdpResponse[] newArray(int size) {
- return new IdpResponse[size];
- }
- };
-
- private final User mUser;
- private final AuthCredential mPendingCredential;
-
- private final String mToken;
- private final String mSecret;
- private final boolean mIsNewUser;
-
- private final FirebaseUiException mException;
-
- private IdpResponse(@NonNull FirebaseUiException e) {
- this(null, null, null, false, e, null);
- }
-
- private IdpResponse(
- @NonNull User user,
- @Nullable String token,
- @Nullable String secret,
- @Nullable AuthCredential pendingCredential,
- boolean isNewUser) {
- this(user, token, secret, isNewUser, null, pendingCredential);
- }
-
- private IdpResponse(AuthCredential credential, FirebaseUiException e) {
- this(null, null, null, false, e, credential);
- }
-
- private IdpResponse(
- User user,
- String token,
- String secret,
- boolean isNewUser,
- FirebaseUiException e,
- AuthCredential credential) {
- mUser = user;
- mToken = token;
- mSecret = secret;
- mIsNewUser = isNewUser;
- mException = e;
- mPendingCredential = credential;
- }
-
- /**
- * Extract the {@link IdpResponse} from the flow's result intent.
- *
- * @param resultIntent The intent which {@code onActivityResult} was called with.
- * @return The IdpResponse containing the token(s) from signing in with the Idp
- */
- @Nullable
- public static IdpResponse fromResultIntent(@Nullable Intent resultIntent) {
- if (resultIntent != null) {
- return resultIntent.getParcelableExtra(ExtraConstants.IDP_RESPONSE);
- } else {
- return null;
- }
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static IdpResponse from(@NonNull Exception e) {
- if (e instanceof FirebaseUiException) {
- return new IdpResponse((FirebaseUiException) e);
- } else if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- return ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- } else if (e instanceof FirebaseUiUserCollisionException) {
- FirebaseUiUserCollisionException collisionException
- = (FirebaseUiUserCollisionException) e;
- // Lint complains about providerId not being
- // in the pre-defined set of constants
- @SuppressLint("WrongConstant") User user = new User.Builder(
- collisionException.getProviderId(),
- collisionException.getEmail())
- .build();
-
- return new IdpResponse(user,
- /* token= */ null,
- /* secret= */ null,
- /* isNewUser= */ false,
- new FirebaseUiException(collisionException.getErrorCode(),
- collisionException.getMessage()),
- collisionException.getCredential());
- } else {
- FirebaseUiException wrapped
- = new FirebaseUiException(ErrorCodes.UNKNOWN_ERROR, e.getMessage());
- wrapped.setStackTrace(e.getStackTrace());
- return new IdpResponse(wrapped);
- }
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static Intent getErrorIntent(@NonNull Exception e) {
- return from(e).toIntent();
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public IdpResponse withResult(AuthResult result) {
- return mutate().setNewUser(result.getAdditionalUserInfo().isNewUser()).build();
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public Intent toIntent() {
- return new Intent().putExtra(ExtraConstants.IDP_RESPONSE, this);
- }
-
- @NonNull
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public Builder mutate() {
- if (!isSuccessful()) {
- throw new IllegalStateException("Cannot mutate an unsuccessful response.");
- }
- return new Builder(this);
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public boolean isSuccessful() {
- return mException == null;
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public User getUser() {
- return mUser;
- }
-
- /**
- * Get the type of provider. e.g. {@link GoogleAuthProvider#PROVIDER_ID}
- */
- @Nullable
- public String getProviderType() {
- return mUser != null ? mUser.getProviderId() : null;
- }
-
- /**
- * Returns true if this user has just signed up, false otherwise.
- */
- public boolean isNewUser() {
- return mIsNewUser;
- }
-
- /**
- * Get the email used to sign in.
- */
- @Nullable
- public String getEmail() {
- return mUser != null ? mUser.getEmail() : null;
- }
-
- /**
- * Get the phone number used to sign in.
- */
- @Nullable
- public String getPhoneNumber() {
- return mUser != null ? mUser.getPhoneNumber() : null;
- }
-
- /**
- * Get the token received as a result of logging in with the specified IDP
- */
- @Nullable
- public String getIdpToken() {
- return mToken;
- }
-
- /**
- * Twitter only. Return the token secret received as a result of logging in with Twitter.
- */
- @Nullable
- public String getIdpSecret() {
- return mSecret;
- }
-
- /**
- * Get the error for a failed sign in.
- */
- @Nullable
- public FirebaseUiException getError() {
- return mException;
- }
-
- @Nullable
- public AuthCredential getCredentialForLinking() {
- return mPendingCredential;
- }
-
- @Nullable
- public boolean hasCredentialForLinking() {
- return mPendingCredential != null;
- }
-
- public boolean isRecoverableErrorResponse() {
- // In a recoverable error flow, both a valid credential that can be used to sign-in and
- // the email keying that account is returned.
- return mPendingCredential != null || getEmail() != null;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeParcelable(mUser, flags);
- dest.writeString(mToken);
- dest.writeString(mSecret);
- dest.writeInt(mIsNewUser ? 1 : 0);
-
- ObjectOutputStream oos = null;
- try {
- oos = new ObjectOutputStream(new ByteArrayOutputStream());
- oos.writeObject(mException);
-
- // Success! The entire exception tree is serializable.
- dest.writeSerializable(mException);
- } catch (IOException e) {
- // Somewhere down the line, the exception is holding on to an object that isn't
- // serializable so default to some exception. It's the best we can do in this case.
- FirebaseUiException fake = new FirebaseUiException(ErrorCodes.UNKNOWN_ERROR,
- "Exception serialization error, forced wrapping. " +
- "Original: " + mException +
- ", original cause: " + mException.getCause());
- fake.setStackTrace(mException.getStackTrace());
- dest.writeSerializable(fake);
- } finally {
- if (oos != null) {
- try {
- oos.close();
- } catch (IOException ignored) {
- }
- }
- }
-
- dest.writeParcelable(mPendingCredential, 0);
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- IdpResponse response = (IdpResponse) o;
-
- return (mUser == null ? response.mUser == null : mUser.equals(response.mUser))
- && (mToken == null ? response.mToken == null : mToken.equals(response.mToken))
- && (mSecret == null ? response.mSecret == null : mSecret.equals(response.mSecret))
- && (mIsNewUser == response.mIsNewUser)
- && (mException == null ? response.mException == null : mException.equals(response
- .mException))
- && (mPendingCredential == null ? response.mPendingCredential == null :
- mPendingCredential.getProvider().equals(response.mPendingCredential.getProvider()));
- }
-
- @Override
- public int hashCode() {
- int result = mUser == null ? 0 : mUser.hashCode();
- result = 31 * result + (mToken == null ? 0 : mToken.hashCode());
- result = 31 * result + (mSecret == null ? 0 : mSecret.hashCode());
- result = 31 * result + (mIsNewUser ? 1 : 0);
- result = 31 * result + (mException == null ? 0 : mException.hashCode());
- result = 31 * result + (mPendingCredential == null ? 0 : mPendingCredential.getProvider()
- .hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- return "IdpResponse{" +
- "mUser=" + mUser +
- ", mToken='" + mToken + '\'' +
- ", mSecret='" + mSecret + '\'' +
- ", mIsNewUser='" + mIsNewUser + '\'' +
- ", mException=" + mException +
- ", mPendingCredential=" + mPendingCredential +
- '}';
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static class Builder {
- private User mUser;
- private AuthCredential mPendingCredential;
- private String mToken;
- private String mSecret;
- private boolean mIsNewUser;
-
- public Builder() {}
-
- public Builder(@NonNull User user) {
- mUser = user;
- }
-
- public Builder(@NonNull IdpResponse response) {
- mUser = response.mUser;
- mToken = response.mToken;
- mSecret = response.mSecret;
- mIsNewUser = response.mIsNewUser;
- mPendingCredential = response.mPendingCredential;
- }
-
- public Builder setNewUser(boolean newUser) {
- mIsNewUser = newUser;
- return this;
- }
-
- public Builder setToken(String token) {
- mToken = token;
- return this;
- }
-
- public Builder setSecret(String secret) {
- mSecret = secret;
- return this;
- }
-
- public Builder setPendingCredential(AuthCredential credential) {
- mPendingCredential = credential;
- return this;
- }
-
- public IdpResponse build() {
- if (mPendingCredential != null && mUser == null) {
- return new IdpResponse(mPendingCredential, new FirebaseUiException(ErrorCodes
- .ANONYMOUS_UPGRADE_MERGE_CONFLICT));
- }
-
- String providerId = mUser.getProviderId();
-
- if (AuthUI.SOCIAL_PROVIDERS.contains(providerId) && TextUtils.isEmpty(mToken)) {
- throw new IllegalStateException(
- "Token cannot be null when using a non-email provider.");
- }
- if (providerId.equals(TwitterAuthProvider.PROVIDER_ID)
- && TextUtils.isEmpty(mSecret)) {
- throw new IllegalStateException(
- "Secret cannot be null when using the Twitter provider.");
- }
-
- return new IdpResponse(mUser, mToken, mSecret, mPendingCredential, mIsNewUser);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java b/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java
deleted file mode 100644
index 3fa619bef..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/KickoffActivity.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.firebase.ui.auth;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.data.remote.SignInKickstarter;
-import com.firebase.ui.auth.ui.InvisibleActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.google.android.gms.common.GoogleApiAvailability;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.android.gms.tasks.Task;
-import com.google.android.gms.tasks.Tasks;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class KickoffActivity extends InvisibleActivityBase {
- public static SignInKickstarter mKickstarter;
-
- public static Intent createIntent(Context context, FlowParameters flowParams) {
- return createBaseIntent(context, KickoffActivity.class, flowParams);
- }
-
- @Override
- protected void onCreate(@Nullable final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mKickstarter = new ViewModelProvider(this).get(SignInKickstarter.class);
- mKickstarter.init(getFlowParams());
- mKickstarter.getOperation().observe(this, new ResourceObserver<>(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- finish(RESULT_OK, response.toIntent());
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof UserCancellationException) {
- finish(RESULT_CANCELED, null);
- } else if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse res = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- finish(RESULT_CANCELED, new Intent().putExtra(ExtraConstants.IDP_RESPONSE,
- res));
- } else {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(e));
- }
- }
- });
-
- Task checkPlayServicesTask = getFlowParams().isPlayServicesRequired()
- ? GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this)
- : Tasks.forResult((Void) null);
-
- checkPlayServicesTask
- .addOnSuccessListener(this, aVoid -> {
- if (savedInstanceState != null) {
- return;
- }
-
- mKickstarter.start();
- })
- .addOnFailureListener(this, e -> finish(RESULT_CANCELED, IdpResponse.getErrorIntent(new FirebaseUiException(
- ErrorCodes.PLAY_SERVICES_UPDATE_CANCELLED, e))));
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
-
- if (requestCode == RequestCodes.EMAIL_FLOW
- && (resultCode == RequestCodes.EMAIL_LINK_WRONG_DEVICE_FLOW
- || resultCode == RequestCodes.EMAIL_LINK_INVALID_LINK_FLOW)) {
- invalidateEmailLink();
- }
-
- mKickstarter.onActivityResult(requestCode, resultCode, data);
- }
-
- public void invalidateEmailLink() {
- FlowParameters flowParameters = getFlowParams();
- flowParameters.emailLink = null;
- setIntent(getIntent().putExtra(ExtraConstants.FLOW_PARAMS,
- flowParameters));
- }
-}
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
new file mode 100644
index 000000000..68087b419
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/AuthUIConfiguration.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration
+
+import android.content.Context
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvidersBuilder
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.google.firebase.auth.ActionCodeSettings
+import java.util.Locale
+
+fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) =
+ AuthUIConfigurationBuilder().apply(block).build()
+
+@DslMarker
+annotation class AuthUIConfigurationDsl
+
+@AuthUIConfigurationDsl
+class AuthUIConfigurationBuilder {
+ var context: Context? = null
+ private val providers = mutableListOf()
+ var theme: AuthUITheme = AuthUITheme.Default
+ var locale: Locale? = null
+ var stringProvider: AuthUIStringProvider? = null
+ var isCredentialManagerEnabled: Boolean = true
+ var isMfaEnabled: Boolean = true
+ var isAnonymousUpgradeEnabled: Boolean = false
+ var tosUrl: String? = null
+ var privacyPolicyUrl: String? = null
+ var logo: AuthUIAsset? = null
+ var passwordResetActionCodeSettings: ActionCodeSettings? = null
+ var isNewEmailAccountsAllowed: Boolean = true
+ var isDisplayNameRequired: Boolean = true
+ var isProviderChoiceAlwaysShown: Boolean = false
+
+ fun providers(block: AuthProvidersBuilder.() -> Unit) =
+ providers.addAll(AuthProvidersBuilder().apply(block).build())
+
+ internal fun build(): AuthUIConfiguration {
+ val context = requireNotNull(context) {
+ "Application context is required"
+ }
+
+ require(providers.isNotEmpty()) {
+ "At least one provider must be configured"
+ }
+
+ // No unsupported providers (allow predefined providers and custom OIDC providers starting with "oidc.")
+ val supportedProviderIds = Provider.entries.map { it.id }.toSet()
+ val unknownProviders = providers.filter { provider ->
+ provider.providerId !in supportedProviderIds && !provider.providerId.startsWith("oidc.")
+ }
+ require(unknownProviders.isEmpty()) {
+ "Unknown providers: ${unknownProviders.joinToString { it.providerId }}"
+ }
+
+ // Cannot have only anonymous provider
+ AuthProvider.Anonymous.validate(providers)
+
+ // Check for duplicate providers
+ val providerIds = providers.map { it.providerId }
+ val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 }
+
+ require(duplicates.isEmpty()) {
+ val message = duplicates.keys.joinToString(", ")
+ throw IllegalArgumentException(
+ "Each provider can only be set once. Duplicates: $message"
+ )
+ }
+
+ // Provider specific validations
+ providers.forEach { provider ->
+ when (provider) {
+ is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled)
+ is AuthProvider.Phone -> provider.validate()
+ is AuthProvider.Google -> provider.validate(context)
+ is AuthProvider.Facebook -> provider.validate(context)
+ is AuthProvider.GenericOAuth -> provider.validate()
+ else -> null
+ }
+ }
+
+ return AuthUIConfiguration(
+ context = context,
+ providers = providers.toList(),
+ theme = theme,
+ locale = locale,
+ stringProvider = stringProvider ?: DefaultAuthUIStringProvider(context, locale),
+ isCredentialManagerEnabled = isCredentialManagerEnabled,
+ isMfaEnabled = isMfaEnabled,
+ isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled,
+ tosUrl = tosUrl,
+ privacyPolicyUrl = privacyPolicyUrl,
+ logo = logo,
+ passwordResetActionCodeSettings = passwordResetActionCodeSettings,
+ isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
+ isDisplayNameRequired = isDisplayNameRequired,
+ isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown
+ )
+ }
+}
+
+/**
+ * Configuration object for the authentication flow.
+ */
+class AuthUIConfiguration(
+ /**
+ * Application context
+ */
+ val context: Context,
+
+ /**
+ * The list of enabled authentication providers.
+ */
+ val providers: List = emptyList(),
+
+ /**
+ * The theming configuration for the UI. Default to [AuthUITheme.Default].
+ */
+ val theme: AuthUITheme = AuthUITheme.Default,
+
+ /**
+ * The locale for internationalization.
+ */
+ val locale: Locale? = null,
+
+ /**
+ * A custom provider for localized strings.
+ */
+ val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context, locale),
+
+ /**
+ * Enables integration with Android's Credential Manager API. Defaults to true.
+ */
+ val isCredentialManagerEnabled: Boolean = true,
+
+ /**
+ * Enables Multi-Factor Authentication support. Defaults to true.
+ */
+ val isMfaEnabled: Boolean = true,
+
+ /**
+ * Allows upgrading an anonymous user to a new credential.
+ */
+ val isAnonymousUpgradeEnabled: Boolean = false,
+
+ /**
+ * The URL for the terms of service.
+ */
+ val tosUrl: String? = null,
+
+ /**
+ * The URL for the privacy policy.
+ */
+ val privacyPolicyUrl: String? = null,
+
+ /**
+ * The logo to display on the authentication screens.
+ */
+ val logo: AuthUIAsset? = null,
+
+ /**
+ * Configuration for sending email reset link.
+ */
+ val passwordResetActionCodeSettings: ActionCodeSettings? = null,
+
+ /**
+ * Allows new email accounts to be created. Defaults to true.
+ */
+ val isNewEmailAccountsAllowed: Boolean = true,
+
+ /**
+ * Requires the user to provide a display name on sign-up. Defaults to true.
+ */
+ val isDisplayNameRequired: Boolean = true,
+
+ /**
+ * Always shows the provider selection screen, even if only one is enabled.
+ */
+ val isProviderChoiceAlwaysShown: Boolean = false,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
new file mode 100644
index 000000000..ed748bfe0
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaConfiguration.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration
+
+/**
+ * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior.
+ *
+ * This class controls which MFA factors are available to users, whether enrollment is mandatory,
+ * and whether recovery codes are generated.
+ *
+ * @property allowedFactors List of MFA factors that users are permitted to enroll in.
+ * Defaults to [MfaFactor.Sms, MfaFactor.Totp].
+ * @property requireEnrollment Whether MFA enrollment is mandatory for all users.
+ * When true, users must enroll in at least one MFA factor.
+ * Defaults to false.
+ * @property enableRecoveryCodes Whether to generate and provide recovery codes to users
+ * after successful MFA enrollment. These codes can be used
+ * as a backup authentication method. Defaults to true.
+ */
+class MfaConfiguration(
+ val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp),
+ val requireEnrollment: Boolean = false,
+ val enableRecoveryCodes: Boolean = true
+) {
+ init {
+ require(allowedFactors.isNotEmpty()) {
+ "At least one MFA factor must be allowed"
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/package-info.java b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
similarity index 50%
rename from auth/src/main/java/com/firebase/ui/auth/util/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
index fb47a43b3..472740560 100644
--- a/auth/src/main/java/com/firebase/ui/auth/util/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/MfaFactor.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
@@ -12,9 +12,22 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.configuration
+
/**
- * Utilities to simplify interactions with {@code GoogleApiClient} and the APIs it provides, such as
- * Google Sign-in and Smart Lock for Passwords. The contents of this package should be considered an
- * implementation detail and not part of the main API.
+ * Represents the different Multi-Factor Authentication (MFA) factors that can be used
+ * for enrollment and verification.
*/
-package com.firebase.ui.auth.util;
+enum class MfaFactor {
+ /**
+ * SMS-based authentication factor.
+ * Users receive a verification code via text message to their registered phone number.
+ */
+ Sms,
+
+ /**
+ * Time-based One-Time Password (TOTP) authentication factor.
+ * Users generate verification codes using an authenticator app.
+ */
+ Totp
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
new file mode 100644
index 000000000..d10f1c811
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/PasswordRule.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * An abstract class representing a set of validation rules that can be applied to a password field,
+ * typically within the [com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Email] configuration.
+ */
+abstract class PasswordRule {
+ /**
+ * Requires the password to have at least a certain number of characters.
+ */
+ class MinimumLength(val value: Int) : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.length >= this@MinimumLength.value
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordTooShort(value)
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one uppercase letter (A-Z).
+ */
+ object RequireUppercase : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isUpperCase() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingUppercase
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one lowercase letter (a-z).
+ */
+ object RequireLowercase : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isLowerCase() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingLowercase
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one numeric digit (0-9).
+ */
+ object RequireDigit : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return password.any { it.isDigit() }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingDigit
+ }
+ }
+
+ /**
+ * Requires the password to contain at least one special character (e.g., !@#$%^&*).
+ */
+ object RequireSpecialCharacter : PasswordRule() {
+ private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet()
+
+ override fun isValid(password: String): Boolean {
+ return password.any { it in specialCharacters }
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return stringProvider.passwordMissingSpecialCharacter
+ }
+ }
+
+ /**
+ * Defines a custom validation rule using a regular expression and provides a specific error
+ * message on failure.
+ */
+ class Custom(
+ val regex: Regex,
+ val errorMessage: String
+ ) : PasswordRule() {
+ override fun isValid(password: String): Boolean {
+ return regex.matches(password)
+ }
+
+ override fun getErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return errorMessage
+ }
+ }
+
+ /**
+ * Validates whether the given password meets this rule's requirements.
+ *
+ * @param password The password to validate
+ * @return true if the password meets this rule's requirements, false otherwise
+ */
+ internal abstract fun isValid(password: String): Boolean
+
+ /**
+ * Returns the appropriate error message for this rule when validation fails.
+ *
+ * @param stringProvider The string provider for localized error messages
+ * @return The localized error message for this rule
+ */
+ internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..009765727
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,129 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Creates a remembered launcher function for anonymous sign-in.
+ *
+ * @return A launcher function that starts the anonymous sign-in flow when invoked
+ *
+ * @see signInAnonymously
+ * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this) {
+ {
+ coroutineScope.launch {
+ try {
+ signInAnonymously()
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in a user anonymously with Firebase Authentication.
+ *
+ * This method creates a temporary anonymous user account that can be used for testing
+ * or as a starting point for users who want to try the app before creating a permanent
+ * account. Anonymous users can later be upgraded to permanent accounts by linking
+ * credentials (email/password, social providers, phone, etc.).
+ *
+ * **Flow:**
+ * 1. Updates auth state to loading with "Signing in anonymously..." message
+ * 2. Calls Firebase Auth's `signInAnonymously()` method
+ * 3. Updates auth state to idle on success
+ * 4. Handles cancellation and converts exceptions to [AuthException] types
+ *
+ * **Anonymous Account Benefits:**
+ * - No user data collection required
+ * - Immediate access to app features
+ * - Can be upgraded to permanent account later
+ * - Useful for guest users and app trials
+ *
+ * **Account Upgrade:**
+ * Anonymous accounts can be upgraded to permanent accounts by calling methods like:
+ * - [signInAndLinkWithCredential] with email/password or social credentials
+ * - [createOrLinkUserWithEmailAndPassword] for email/password accounts
+ * - [signInWithPhoneAuthCredential] for phone authentication
+ *
+ * **Example: Basic anonymous sign-in**
+ * ```kotlin
+ * try {
+ * firebaseAuthUI.signInAnonymously()
+ * // User is now signed in anonymously
+ * // Show app content or prompt for account creation
+ * } catch (e: AuthException.AuthCancelledException) {
+ * // User cancelled the sign-in process
+ * } catch (e: AuthException.NetworkException) {
+ * // Network error occurred
+ * }
+ * ```
+ *
+ * **Example: Anonymous sign-in with upgrade flow**
+ * ```kotlin
+ * // Step 1: Sign in anonymously
+ * firebaseAuthUI.signInAnonymously()
+ *
+ * // Step 2: Later, upgrade to permanent account
+ * try {
+ * firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "John Doe",
+ * email = "john@example.com",
+ * password = "SecurePass123!"
+ * )
+ * // Anonymous account upgraded to permanent email/password account
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Email already exists - show account linking UI
+ * }
+ * ```
+ *
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other authentication errors
+ *
+ * @see signInAndLinkWithCredential for upgrading anonymous accounts
+ * @see createOrLinkUserWithEmailAndPassword for email/password upgrade
+ * @see signInWithPhoneAuthCredential for phone authentication upgrade
+ */
+internal suspend fun FirebaseAuthUI.signInAnonymously() {
+ try {
+ updateAuthState(AuthState.Loading("Signing in anonymously..."))
+ auth.signInAnonymously().await()
+ updateAuthState(AuthState.Idle)
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in anonymously was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
new file mode 100644
index 000000000..fb8e55775
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AuthProvider.kt
@@ -0,0 +1,1006 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.app.Activity
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.annotation.RestrictTo
+import androidx.compose.ui.graphics.Color
+import androidx.core.net.toUri
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.facebook.AccessToken
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.AuthUIConfigurationDsl
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.util.ContinueUrlBuilder
+import com.firebase.ui.auth.util.PhoneNumberUtils
+import com.firebase.ui.auth.util.Preconditions
+import com.firebase.ui.auth.util.ProviderAvailability
+import com.google.android.gms.auth.api.identity.AuthorizationRequest
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.android.gms.common.api.Scope
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.firebase.FirebaseException
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FacebookAuthProvider
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.GithubAuthProvider
+import com.google.firebase.auth.GoogleAuthProvider
+import com.google.firebase.auth.MultiFactorSession
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthOptions
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.TwitterAuthProvider
+import com.google.firebase.auth.UserProfileChangeRequest
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.tasks.await
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+@AuthUIConfigurationDsl
+class AuthProvidersBuilder {
+ private val providers = mutableListOf()
+
+ fun provider(provider: AuthProvider) {
+ providers.add(provider)
+ }
+
+ internal fun build(): List = providers.toList()
+}
+
+/**
+ * Enum class to represent all possible providers.
+ */
+internal enum class Provider(
+ val id: String,
+ val providerName: String,
+ val isSocialProvider: Boolean = false,
+) {
+ GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google", isSocialProvider = true),
+ FACEBOOK(FacebookAuthProvider.PROVIDER_ID, providerName = "Facebook", isSocialProvider = true),
+ TWITTER(TwitterAuthProvider.PROVIDER_ID, providerName = "Twitter", isSocialProvider = true),
+ GITHUB(GithubAuthProvider.PROVIDER_ID, providerName = "Github", isSocialProvider = true),
+ EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"),
+ PHONE(PhoneAuthProvider.PROVIDER_ID, providerName = "Phone"),
+ ANONYMOUS("anonymous", providerName = "Anonymous"),
+ MICROSOFT("microsoft.com", providerName = "Microsoft", isSocialProvider = true),
+ YAHOO("yahoo.com", providerName = "Yahoo", isSocialProvider = true),
+ APPLE("apple.com", providerName = "Apple", isSocialProvider = true);
+
+ companion object {
+ fun fromId(id: String?): Provider? {
+ return entries.find { it.id == id }
+ }
+ }
+}
+
+/**
+ * Base abstract class for authentication providers.
+ */
+abstract class AuthProvider(open val providerId: String, open val providerName: String) {
+ /**
+ * Base abstract class for OAuth authentication providers with common properties.
+ */
+ abstract class OAuth(
+ override val providerId: String,
+
+ override val providerName: String,
+ open val scopes: List = emptyList(),
+ open val customParameters: Map = emptyMap(),
+ ) : AuthProvider(providerId = providerId, providerName = providerName)
+
+ /**
+ * Email/Password authentication provider configuration.
+ */
+ class Email(
+ /**
+ * Requires the user to provide a display name. Defaults to true.
+ */
+ val isDisplayNameRequired: Boolean = true,
+
+ /**
+ * Enables email link sign-in, Defaults to false.
+ */
+ val isEmailLinkSignInEnabled: Boolean = false,
+
+ /**
+ * Forces email link sign-in to complete on the same device that initiated it.
+ *
+ * When enabled, prevents email links from being opened on different devices,
+ * which is required for security when upgrading anonymous users. Defaults to true.
+ */
+ val isEmailLinkForceSameDeviceEnabled: Boolean = true,
+
+ /**
+ * Settings for email link actions.
+ */
+ val emailLinkActionCodeSettings: ActionCodeSettings?,
+
+ /**
+ * Allows new accounts to be created. Defaults to true.
+ */
+ val isNewAccountsAllowed: Boolean = true,
+
+ /**
+ * The minimum length for a password. Defaults to 6.
+ */
+ val minimumPasswordLength: Int = 6,
+
+ /**
+ * A list of custom password validation rules.
+ */
+ val passwordValidationRules: List,
+ ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {
+ companion object {
+ const val SESSION_ID_LENGTH = 10
+ val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email")
+ val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider")
+ val KEY_ANONYMOUS_USER_ID =
+ stringPreferencesKey("com.firebase.ui.auth.data.client.auid")
+ val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid")
+ val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken")
+ val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret")
+ }
+
+ internal fun validate(isAnonymousUpgradeEnabled: Boolean = false) {
+ if (isEmailLinkSignInEnabled) {
+ val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) {
+ "ActionCodeSettings cannot be null when using " +
+ "email link sign in."
+ }
+
+ check(actionCodeSettings.canHandleCodeInApp()) {
+ "You must set canHandleCodeInApp in your " +
+ "ActionCodeSettings to true for Email-Link Sign-in."
+ }
+
+ if (isAnonymousUpgradeEnabled) {
+ check(isEmailLinkForceSameDeviceEnabled) {
+ "You must force the same device flow when using email link sign in " +
+ "with anonymous user upgrade"
+ }
+ }
+ }
+ }
+
+ // For Send Email Link
+ internal fun addSessionInfoToActionCodeSettings(
+ sessionId: String,
+ anonymousUserId: String,
+ credentialForLinking: AuthCredential? = null,
+ ): ActionCodeSettings {
+ requireNotNull(emailLinkActionCodeSettings) {
+ "ActionCodeSettings is required for email link sign in"
+ }
+
+ val continueUrl = continueUrl(emailLinkActionCodeSettings.url) {
+ appendSessionId(sessionId)
+ appendAnonymousUserId(anonymousUserId)
+ appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled)
+ // Only append providerId for linking flows (when credentialForLinking is not null)
+ if (credentialForLinking != null) {
+ appendProviderId(credentialForLinking.provider)
+ }
+ }
+
+ return actionCodeSettings {
+ url = continueUrl
+ handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp()
+ iosBundleId = emailLinkActionCodeSettings.iosBundle
+ setAndroidPackageName(
+ emailLinkActionCodeSettings.androidPackageName ?: "",
+ emailLinkActionCodeSettings.androidInstallApp,
+ emailLinkActionCodeSettings.androidMinimumVersion
+ )
+ }
+ }
+
+ // For Sign In With Email Link
+ internal fun isDifferentDevice(
+ sessionIdFromLocal: String?,
+ sessionIdFromLink: String,
+ ): Boolean {
+ return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty()
+ || sessionIdFromLink.isEmpty()
+ || (sessionIdFromLink != sessionIdFromLocal)
+ }
+
+ private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) =
+ ContinueUrlBuilder(continueUrl).apply(block).build()
+
+ /**
+ * An interface to wrap the static `EmailAuthProvider.getCredential` method to make it testable.
+ * @suppress
+ */
+ internal interface CredentialProvider {
+ fun getCredential(email: String, password: String): AuthCredential
+ }
+
+ /**
+ * The default implementation of [CredentialProvider] that calls the static method.
+ * @suppress
+ */
+ internal class DefaultCredentialProvider : CredentialProvider {
+ override fun getCredential(email: String, password: String): AuthCredential {
+ return EmailAuthProvider.getCredential(email, password)
+ }
+ }
+ }
+
+ /**
+ * Phone number authentication provider configuration.
+ */
+ class Phone(
+ /**
+ * The phone number in international format.
+ */
+ val defaultNumber: String?,
+
+ /**
+ * The default country code to pre-select.
+ */
+ val defaultCountryCode: String?,
+
+ /**
+ * A list of allowed country codes.
+ */
+ val allowedCountries: List?,
+
+ /**
+ * The expected length of the SMS verification code. Defaults to 6.
+ */
+ val smsCodeLength: Int = 6,
+
+ /**
+ * The timeout in seconds for receiving the SMS. Defaults to 60L.
+ */
+ val timeout: Long = 60L,
+
+ /**
+ * Enables instant verification of the phone number. Defaults to true.
+ */
+ val isInstantVerificationEnabled: Boolean = true,
+ ) : AuthProvider(providerId = Provider.PHONE.id, providerName = Provider.PHONE.providerName) {
+ /**
+ * Sealed class representing the result of phone number verification.
+ *
+ * Phone verification can complete in two ways:
+ * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK
+ * - [NeedsManualVerification]: SMS code was sent, user must manually enter it
+ */
+ internal sealed class VerifyPhoneNumberResult {
+ /**
+ * Instant verification succeeded via SMS auto-retrieval.
+ *
+ * @property credential The [PhoneAuthCredential] that can be used to sign in
+ */
+ class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult()
+
+ /**
+ * Instant verification failed, manual code entry required.
+ *
+ * @property verificationId The verification ID to use when submitting the code
+ * @property token Token for resending the verification code
+ */
+ class NeedsManualVerification(
+ val verificationId: String,
+ val token: PhoneAuthProvider.ForceResendingToken,
+ ) : VerifyPhoneNumberResult()
+ }
+
+ internal fun validate() {
+ defaultNumber?.let {
+ check(PhoneNumberUtils.isValid(it)) {
+ "Invalid phone number: $it"
+ }
+ }
+
+ defaultCountryCode?.let {
+ check(PhoneNumberUtils.isValidIso(it)) {
+ "Invalid country iso: $it"
+ }
+ }
+
+ allowedCountries?.forEach { code ->
+ check(PhoneNumberUtils.isValidIso(code)) {
+ "Invalid input: You must provide a valid country iso (alpha-2) " +
+ "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code"
+ }
+ }
+ }
+
+ /**
+ * Internal coroutine-based wrapper for Firebase Phone Authentication verification.
+ *
+ * This method wraps the callback-based Firebase Phone Auth API into a suspending function
+ * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks]
+ * and converts them into a [VerifyPhoneNumberResult].
+ *
+ * **Callback mapping:**
+ * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified]
+ * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification]
+ * - `onVerificationFailed` → throws the exception
+ *
+ * This is a private helper method used by [verifyPhoneNumber]. Callers should use
+ * [verifyPhoneNumber] instead as it handles state management and error handling.
+ *
+ * @param auth The [FirebaseAuth] instance to use for verification
+ * @param phoneNumber The phone number to verify in E.164 format
+ * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided,
+ * Firebase verifies the phone number for enrolling as a second authentication factor
+ * instead of primary sign-in. Pass null for standard phone authentication.
+ * @param forceResendingToken Optional token from previous verification for resending
+ *
+ * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed
+ * @throws FirebaseException if verification fails
+ */
+ internal suspend fun verifyPhoneNumberAwait(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ multiFactorSession: MultiFactorSession? = null,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ verifier: Verifier = DefaultVerifier(),
+ ): VerifyPhoneNumberResult {
+ return verifier.verifyPhoneNumber(
+ auth,
+ activity,
+ phoneNumber,
+ timeout,
+ forceResendingToken,
+ multiFactorSession,
+ isInstantVerificationEnabled
+ )
+ }
+
+ /**
+ * @suppress
+ */
+ internal interface Verifier {
+ suspend fun verifyPhoneNumber(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ timeout: Long,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ multiFactorSession: MultiFactorSession?,
+ isInstantVerificationEnabled: Boolean,
+ ): VerifyPhoneNumberResult
+ }
+
+ /**
+ * @suppress
+ */
+ internal class DefaultVerifier : Verifier {
+ override suspend fun verifyPhoneNumber(
+ auth: FirebaseAuth,
+ activity: Activity?,
+ phoneNumber: String,
+ timeout: Long,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ multiFactorSession: MultiFactorSession?,
+ isInstantVerificationEnabled: Boolean,
+ ): VerifyPhoneNumberResult {
+ return suspendCoroutine { continuation ->
+ val options = PhoneAuthOptions.newBuilder(auth)
+ .setPhoneNumber(phoneNumber)
+ .requireSmsValidation(!isInstantVerificationEnabled)
+ .setTimeout(timeout, TimeUnit.SECONDS)
+ .setCallbacks(object :
+ PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
+ override fun onVerificationCompleted(credential: PhoneAuthCredential) {
+ continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential))
+ }
+
+ override fun onVerificationFailed(e: FirebaseException) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onCodeSent(
+ verificationId: String,
+ token: PhoneAuthProvider.ForceResendingToken,
+ ) {
+ continuation.resume(
+ VerifyPhoneNumberResult.NeedsManualVerification(
+ verificationId,
+ token
+ )
+ )
+ }
+ })
+ .apply {
+ activity?.let {
+ setActivity(it)
+ }
+ forceResendingToken?.let {
+ setForceResendingToken(it)
+ }
+ multiFactorSession?.let {
+ setMultiFactorSession(it)
+ }
+ }
+ .build()
+ PhoneAuthProvider.verifyPhoneNumber(options)
+ }
+ }
+ }
+
+ /**
+ * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable.
+ * @suppress
+ */
+ internal interface CredentialProvider {
+ fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential
+ }
+
+ /**
+ * The default implementation of [CredentialProvider] that calls the static method.
+ * @suppress
+ */
+ internal class DefaultCredentialProvider : CredentialProvider {
+ override fun getCredential(
+ verificationId: String,
+ smsCode: String,
+ ): PhoneAuthCredential {
+ return PhoneAuthProvider.getCredential(verificationId, smsCode)
+ }
+ }
+
+ }
+
+ /**
+ * Google Sign-In provider configuration.
+ */
+ class Google(
+ /**
+ * The list of scopes to request.
+ */
+ override val scopes: List,
+
+ /**
+ * The OAuth 2.0 client ID for your server.
+ */
+ val serverClientId: String?,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map = emptyMap(),
+ ) : OAuth(
+ providerId = Provider.GOOGLE.id,
+ providerName = Provider.GOOGLE.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate(context: Context) {
+ if (serverClientId == null) {
+ Preconditions.checkConfigured(
+ context,
+ "Check your google-services plugin configuration, the" +
+ " default_web_client_id string wasn't populated.",
+ R.string.default_web_client_id
+ )
+ } else {
+ require(serverClientId.isNotBlank()) {
+ "Server client ID cannot be blank."
+ }
+ }
+
+ val hasEmailScope = scopes.contains("email")
+ if (!hasEmailScope) {
+ Log.w(
+ "AuthProvider.Google",
+ "The scopes do not include 'email'. In most cases this is a mistake!"
+ )
+ }
+ }
+
+ /**
+ * Result container for Google Sign-In credential flow.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ data class GoogleSignInResult(
+ val credential: AuthCredential,
+ val idToken: String,
+ val displayName: String?,
+ val photoUrl: Uri?
+ )
+
+ /**
+ * An interface to wrap the Authorization API for requesting OAuth scopes.
+ * @suppress
+ */
+ internal interface AuthorizationProvider {
+ suspend fun authorize(context: Context, scopes: List)
+ }
+
+ /**
+ * The default implementation of [AuthorizationProvider].
+ * @suppress
+ */
+ internal class DefaultAuthorizationProvider : AuthorizationProvider {
+ override suspend fun authorize(context: Context, scopes: List) {
+ val authorizationRequest = AuthorizationRequest.builder()
+ .setRequestedScopes(scopes)
+ .build()
+
+ Identity.getAuthorizationClient(context)
+ .authorize(authorizationRequest)
+ .await()
+ }
+ }
+
+ /**
+ * An interface to wrap the Credential Manager flow for Google Sign-In.
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ interface CredentialManagerProvider {
+ suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean
+ ): GoogleSignInResult
+ }
+
+ /**
+ * The default implementation of [CredentialManagerProvider].
+ * @suppress
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ class DefaultCredentialManagerProvider : CredentialManagerProvider {
+ override suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean,
+ ): GoogleSignInResult {
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setServerClientId(serverClientId)
+ .setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
+ .setAutoSelectEnabled(autoSelectEnabled)
+ .build()
+
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
+
+ val result = credentialManager.getCredential(context, request)
+ val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(result.credential.data)
+ val credential = GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)
+
+ return GoogleSignInResult(
+ credential = credential,
+ idToken = googleIdTokenCredential.idToken,
+ displayName = googleIdTokenCredential.displayName,
+ photoUrl = googleIdTokenCredential.profilePictureUri,
+ )
+ }
+ }
+ }
+
+ /**
+ * Facebook Login provider configuration.
+ */
+ class Facebook(
+ /**
+ * The Facebook application ID.
+ */
+ val applicationId: String? = null,
+
+ /**
+ * The list of scopes (permissions) to request. Defaults to email and public_profile.
+ */
+ override val scopes: List = listOf("email", "public_profile"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map = emptyMap(),
+ ) : OAuth(
+ providerId = Provider.FACEBOOK.id,
+ providerName = Provider.FACEBOOK.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate(context: Context) {
+ if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) {
+ throw RuntimeException(
+ "Facebook provider cannot be configured " +
+ "without dependency. Did you forget to add " +
+ "'com.facebook.android:facebook-login:VERSION' dependency?"
+ )
+ }
+
+ if (applicationId == null) {
+ Preconditions.checkConfigured(
+ context,
+ "Facebook provider unconfigured. Make sure to " +
+ "add a `facebook_application_id` string or provide applicationId parameter.",
+ R.string.facebook_application_id
+ )
+ } else {
+ require(applicationId.isNotBlank()) {
+ "Facebook application ID cannot be blank"
+ }
+ }
+ }
+
+ /**
+ * An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable.
+ * @suppress
+ */
+ internal interface CredentialProvider {
+ fun getCredential(token: String): AuthCredential
+ }
+
+ /**
+ * The default implementation of [CredentialProvider] that calls the static method.
+ * @suppress
+ */
+ internal class DefaultCredentialProvider : CredentialProvider {
+ override fun getCredential(token: String): AuthCredential {
+ return FacebookAuthProvider.getCredential(token)
+ }
+ }
+
+ /**
+ * Internal data class to hold Facebook profile information.
+ */
+ internal class FacebookProfileData(
+ val displayName: String?,
+ val email: String?,
+ val photoUrl: Uri?,
+ )
+
+ /**
+ * Fetches user profile data from Facebook Graph API.
+ *
+ * @param accessToken The Facebook access token
+ * @return FacebookProfileData containing user's display name, email, and photo URL
+ */
+ internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? {
+ return suspendCancellableCoroutine { continuation ->
+ val request =
+ com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response ->
+ try {
+ val error = response?.error
+ if (error != null) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Graph API error: ${error.errorMessage}"
+ )
+ continuation.resume(null)
+ return@newMeRequest
+ }
+
+ if (jsonObject == null) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Graph API returned null response"
+ )
+ continuation.resume(null)
+ return@newMeRequest
+ }
+
+ val name = jsonObject.optString("name")
+ val email = jsonObject.optString("email")
+
+ // Extract photo URL from picture object
+ val photoUrl = try {
+ jsonObject.optJSONObject("picture")
+ ?.optJSONObject("data")
+ ?.optString("url")
+ ?.takeIf { it.isNotEmpty() }?.toUri()
+ } catch (e: Exception) {
+ Log.w(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Error parsing photo URL",
+ e
+ )
+ null
+ }
+
+ Log.d(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}"
+ )
+
+ continuation.resume(
+ FacebookProfileData(
+ displayName = name,
+ email = email,
+ photoUrl = photoUrl
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(
+ "FirebaseAuthUI.signInWithFacebook",
+ "Error processing Graph API response",
+ e
+ )
+ continuation.resume(null)
+ }
+ }
+
+ // Request specific fields: id, name, email, and picture
+ val parameters = android.os.Bundle().apply {
+ putString("fields", "id,name,email,picture")
+ }
+ request.parameters = parameters
+ request.executeAsync()
+ }
+ }
+ }
+
+ /**
+ * Twitter/X authentication provider configuration.
+ */
+ class Twitter(
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.TWITTER.id,
+ providerName = Provider.TWITTER.providerName,
+ customParameters = customParameters
+ )
+
+ /**
+ * Github authentication provider configuration.
+ */
+ class Github(
+ /**
+ * The list of scopes to request. Defaults to user:email.
+ */
+ override val scopes: List = listOf("user:email"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.GITHUB.id,
+ providerName = Provider.GITHUB.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Microsoft authentication provider configuration.
+ */
+ class Microsoft(
+ /**
+ * The list of scopes to request. Defaults to openid, profile, email.
+ */
+ override val scopes: List = listOf("openid", "profile", "email"),
+
+ /**
+ * The tenant ID for Azure Active Directory.
+ */
+ val tenant: String?,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.MICROSOFT.id,
+ providerName = Provider.MICROSOFT.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Yahoo authentication provider configuration.
+ */
+ class Yahoo(
+ /**
+ * The list of scopes to request. Defaults to openid, profile, email.
+ */
+ override val scopes: List = listOf("openid", "profile", "email"),
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.YAHOO.id,
+ providerName = Provider.YAHOO.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Apple Sign-In provider configuration.
+ */
+ class Apple(
+ /**
+ * The list of scopes to request. Defaults to name and email.
+ */
+ override val scopes: List = listOf("name", "email"),
+
+ /**
+ * The locale for the sign-in page.
+ */
+ val locale: String?,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+ ) : OAuth(
+ providerId = Provider.APPLE.id,
+ providerName = Provider.APPLE.providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ )
+
+ /**
+ * Anonymous authentication provider. It has no configurable properties.
+ */
+ object Anonymous : AuthProvider(
+ providerId = Provider.ANONYMOUS.id,
+ providerName = Provider.ANONYMOUS.providerName
+ ) {
+ internal fun validate(providers: List) {
+ if (providers.size == 1 && providers.first() is Anonymous) {
+ throw IllegalStateException(
+ "Sign in as guest cannot be the only sign in method. " +
+ "In this case, sign the user in anonymously your self; no UI is needed."
+ )
+ }
+ }
+ }
+
+ /**
+ * A generic OAuth provider for any unsupported provider.
+ */
+ class GenericOAuth(
+ /**
+ * The provider name.
+ */
+ override val providerName: String,
+
+ /**
+ * The provider ID as configured in the Firebase console.
+ */
+ override val providerId: String,
+
+ /**
+ * The list of scopes to request.
+ */
+ override val scopes: List,
+
+ /**
+ * A map of custom OAuth parameters.
+ */
+ override val customParameters: Map,
+
+ /**
+ * The text to display on the provider button.
+ */
+ val buttonLabel: String,
+
+ /**
+ * An optional icon for the provider button.
+ */
+ val buttonIcon: AuthUIAsset?,
+
+ /**
+ * An optional background color for the provider button.
+ */
+ val buttonColor: Color?,
+
+ /**
+ * An optional content color for the provider button.
+ */
+ val contentColor: Color?,
+ ) : OAuth(
+ providerId = providerId,
+ providerName = providerName,
+ scopes = scopes,
+ customParameters = customParameters
+ ) {
+ internal fun validate() {
+ require(providerId.isNotBlank()) {
+ "Provider ID cannot be null or empty"
+ }
+
+ require(buttonLabel.isNotBlank()) {
+ "Button label cannot be null or empty"
+ }
+ }
+ }
+
+ companion object {
+ internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
+ val currentUser = auth.currentUser
+ return config.isAnonymousUpgradeEnabled
+ && currentUser != null
+ && currentUser.isAnonymous
+ }
+
+ /**
+ * Merges profile information (display name and photo URL) with the current user's profile.
+ *
+ * This method updates the user's profile only if the current profile is incomplete
+ * (missing display name or photo URL). This prevents overwriting existing profile data.
+ *
+ * **Use case:**
+ * After creating a new user account or linking credentials, update the profile with
+ * information from the sign-up form or social provider.
+ *
+ * @param auth The [FirebaseAuth] instance
+ * @param displayName The display name to set (if current is empty)
+ * @param photoUri The photo URL to set (if current is null)
+ *
+ * **Note:** This operation always succeeds to minimize login interruptions.
+ * Failures are logged but don't prevent sign-in completion.
+ */
+ internal suspend fun mergeProfile(
+ auth: FirebaseAuth,
+ displayName: String?,
+ photoUri: Uri?,
+ ) {
+ try {
+ val currentUser = auth.currentUser ?: return
+
+ // Only update if current profile is incomplete
+ val currentDisplayName = currentUser.displayName
+ val currentPhotoUrl = currentUser.photoUrl
+
+ if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) {
+ // Profile is complete, no need to update
+ return
+ }
+
+ // Build profile update with provided values
+ val nameToSet =
+ if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName
+ val photoToSet = currentPhotoUrl ?: photoUri
+
+ if (nameToSet != null || photoToSet != null) {
+ val profileUpdates = UserProfileChangeRequest.Builder()
+ .setDisplayName(nameToSet)
+ .setPhotoUri(photoToSet)
+ .build()
+
+ currentUser.updateProfile(profileUpdates).await()
+ }
+ } catch (e: Exception) {
+ // Log error but don't throw - profile update failure shouldn't prevent sign-in
+ Log.e("AuthProvider.Email", "Error updating profile", e)
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..878fd03a5
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,1108 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.content.Context
+import android.net.Uri
+import com.firebase.ui.auth.R
+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.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile
+import com.firebase.ui.auth.util.EmailLinkPersistenceManager
+import com.firebase.ui.auth.util.EmailLinkParser
+import com.firebase.ui.auth.util.PersistenceManager
+import com.firebase.ui.auth.util.SessionUtils
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.ActionCodeSettings
+import com.google.firebase.auth.AuthCredential
+import com.google.firebase.auth.AuthResult
+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 kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.tasks.await
+
+
+/**
+ * Creates an email/password account or links the credential to an anonymous user.
+ *
+ * Mirrors the legacy email sign-up handler: validates password strength, validates custom
+ * password rules, checks if new accounts are allowed, chooses between
+ * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name
+ * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException] when
+ * anonymous upgrade encounters an existing account for the email.
+ *
+ * **Flow:**
+ * 1. Check if new accounts are allowed (for non-upgrade flows)
+ * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength]
+ * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules]
+ * 4. If upgrading anonymous user: link credential to existing anonymous account
+ * 5. Otherwise: create new account with `createUserWithEmailAndPassword`
+ * 6. Merge display name into user profile
+ *
+ * @param context Android [Context] for localized strings
+ * @param config Auth UI configuration describing provider settings
+ * @param provider Email provider configuration
+ * @param name Optional display name collected during sign-up
+ * @param email Email address for the new account
+ * @param password Password for the new account
+ *
+ * @return [AuthResult] containing the newly created or linked user, or null if failed
+ *
+ * @throws AuthException.UserNotFoundException if new accounts are not allowed
+ * @throws AuthException.WeakPasswordException if the password fails validation rules
+ * @throws AuthException.InvalidCredentialsException if the email or password is invalid
+ * @throws AuthException.EmailAlreadyInUseException if the email already exists
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException for network-related failures
+ *
+ * **Example: Normal sign-up**
+ * ```kotlin
+ * try {
+ * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "John Doe",
+ * email = "john@example.com",
+ * password = "SecurePass123!"
+ * )
+ * // User account created successfully
+ * } catch (e: AuthException.WeakPasswordException) {
+ * // Password doesn't meet validation rules
+ * } catch (e: AuthException.EmailAlreadyInUseException) {
+ * // Email already exists - redirect to sign-in
+ * }
+ * ```
+ *
+ * **Example: Anonymous user upgrade**
+ * ```kotlin
+ * // User is currently signed in anonymously
+ * try {
+ * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * name = "Jane Smith",
+ * email = "jane@example.com",
+ * password = "MyPassword456"
+ * )
+ * // Anonymous account upgraded to permanent email/password account
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Email already exists - show account linking UI
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ name: String?,
+ email: String,
+ password: String,
+ credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(),
+): AuthResult? {
+ val canUpgrade = canUpgradeAnonymous(config, auth)
+ val pendingCredential =
+ if (canUpgrade) credentialProvider.getCredential(email, password) else null
+
+ try {
+ // Check if new accounts are allowed (only for non-upgrade flows)
+ if (!canUpgrade && !provider.isNewAccountsAllowed) {
+ throw AuthException.UserNotFoundException(
+ message = context.getString(R.string.fui_error_email_does_not_exist)
+ )
+ }
+
+ // Validate minimum password length
+ if (password.length < provider.minimumPasswordLength) {
+ throw AuthException.InvalidCredentialsException(
+ message = context.getString(R.string.fui_error_password_too_short)
+ .format(provider.minimumPasswordLength)
+ )
+ }
+
+ // Validate password against custom rules
+ for (rule in provider.passwordValidationRules) {
+ if (!rule.isValid(password)) {
+ throw AuthException.WeakPasswordException(
+ message = rule.getErrorMessage(config.stringProvider),
+ reason = "Password does not meet custom validation rules"
+ )
+ }
+ }
+
+ updateAuthState(AuthState.Loading("Creating user..."))
+ val result = if (canUpgrade) {
+ auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await()
+ } else {
+ auth.createUserWithEmailAndPassword(email, password).await()
+ }.also { authResult ->
+ authResult?.user?.let {
+ // Merge display name into profile (photoUri is always null for email/password)
+ mergeProfile(auth, name, null)
+ }
+ }
+ updateAuthState(AuthState.Idle)
+ return result
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: email already exists
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account.",
+ email = e.email ?: email,
+ credential = if (canUpgrade) {
+ e.updatedCredential ?: pendingCredential
+ } else {
+ null
+ },
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Create or link user with email and password was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user with email and password, optionally linking a social credential.
+ *
+ * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade
+ * scenarios, it validates credentials in a scratch auth instance before throwing
+ * [AuthException.AccountLinkingRequiredException].
+ *
+ * **Flow:**
+ * 1. If anonymous upgrade:
+ * - Create scratch auth instance to validate credential
+ * - If linking social provider: sign in with email, then link social credential (safe link)
+ * - Otherwise: just validate email credential
+ * - Throw [AuthException.AccountLinkingRequiredException] after successful validation
+ * 2. If normal sign-in:
+ * - Sign in with email/password
+ * - If credential provided: link it and merge profile
+ *
+ * @param context Android [Context] for creating scratch auth instance
+ * @param config Auth UI configuration describing provider settings
+ * @param email Email address for sign-in
+ * @param password Password for sign-in
+ * @param credentialForLinking Optional social provider credential to link after sign-in
+ *
+ * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade)
+ *
+ * @throws AuthException.InvalidCredentialsException if email or password is incorrect
+ * @throws AuthException.UserNotFoundException if the user doesn't exist
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException for network-related failures
+ *
+ * **Example: Normal sign-in**
+ * ```kotlin
+ * try {
+ * val result = firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * password = "password123"
+ * )
+ * // User signed in successfully
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Wrong password
+ * }
+ * ```
+ *
+ * **Example: Sign-in with social credential linking**
+ * ```kotlin
+ * // User tried to sign in with Google, but account exists with email/password
+ * // Prompt for password, then link Google credential
+ * val googleCredential = GoogleAuthProvider.getCredential(idToken, null)
+ *
+ * val result = firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * password = "password123",
+ * credentialForLinking = googleCredential
+ * )
+ * // User signed in with email/password AND Google is now linked
+ * // Profile updated with Google display name and photo
+ * ```
+ *
+ * **Example: Anonymous upgrade validation**
+ * ```kotlin
+ * // User is anonymous, wants to upgrade with existing email/password account
+ * try {
+ * firebaseAuthUI.signInWithEmailAndPassword(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "existing@example.com",
+ * password = "password123"
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Account linking required - UI shows account linking screen
+ * // User needs to sign in with existing account to link anonymous account
+ * }
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword(
+ context: Context,
+ config: AuthUIConfiguration,
+ email: String,
+ password: String,
+ credentialForLinking: AuthCredential? = null,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in..."))
+ return if (canUpgradeAnonymous(config, auth)) {
+ // Anonymous upgrade flow: validate credential in scratch auth
+ val credentialToValidate = EmailAuthProvider.getCredential(email, password)
+
+ // Check if we're linking a social provider credential
+ val isSocialProvider = credentialForLinking != null &&
+ (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false)
+
+ // Create scratch auth instance to avoid losing anonymous user state
+ val appExplicitlyForValidation = FirebaseApp.initializeApp(
+ context,
+ auth.app.options,
+ "FUIAuthScratchApp_${System.currentTimeMillis()}"
+ )
+ val authExplicitlyForValidation = FirebaseAuth
+ .getInstance(appExplicitlyForValidation)
+
+ if (isSocialProvider) {
+ // Safe link: sign in with email, then link social credential
+ authExplicitlyForValidation
+ .signInWithCredential(credentialToValidate).await()
+ .user?.linkWithCredential(credentialForLinking)?.await()
+ .also {
+ // Throw AccountLinkingRequiredException after successful validation
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = credentialToValidate,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ } else {
+ // Just validate the email credential
+ // No linking for non-federated IDPs
+ authExplicitlyForValidation
+ .signInWithCredential(credentialToValidate).await()
+ .also {
+ // Throw AccountLinkingRequiredException after successful validation
+ // Account exists and user is anonymous - needs to link accounts
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = credentialToValidate,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ }
+ } else {
+ // Normal sign-in
+ auth.signInWithEmailAndPassword(email, password).await()
+ .let { result ->
+ // If there's a credential to link, link it after sign-in
+ if (credentialForLinking != null) {
+ val linkResult = result.user
+ ?.linkWithCredential(credentialForLinking)
+ ?.await()
+
+ // Merge profile from social provider
+ linkResult?.user?.let { user ->
+ mergeProfile(
+ auth,
+ user.displayName,
+ user.photoUrl
+ )
+ }
+
+ linkResult ?: result
+ } else {
+ result
+ }
+ }
+ }.also {
+ updateAuthState(AuthState.Idle)
+ }
+ } catch (e: FirebaseAuthMultiFactorException) {
+ // MFA required - extract resolver and update state
+ val resolver = e.resolver
+ val hint = resolver.hints.firstOrNull()?.displayName
+ updateAuthState(AuthState.RequiresMfa(resolver, hint))
+ return null
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with email and password was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in with a credential or links it to an existing anonymous user.
+ *
+ * This method handles both normal sign-in and anonymous upgrade flows. After successful
+ * authentication, it merges profile information (display name and photo URL) into the
+ * Firebase user profile if provided.
+ *
+ * **Flow:**
+ * 1. Check if user is anonymous and upgrade is enabled
+ * 2. If yes: Link credential to anonymous user
+ * 3. If no: Sign in with credential
+ * 4. Merge profile information (name, photo) into Firebase user
+ * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException]
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param credential The [AuthCredential] to use for authentication. Can be from any provider.
+ * @param displayName Optional display name from the provider to merge into the user profile
+ * @param photoUrl Optional photo URL from the provider to merge into the user profile
+ *
+ * @return [AuthResult] containing the authenticated user
+ *
+ * @throws AuthException.InvalidCredentialsException if credential is invalid or expired
+ * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ *
+ * **Example: Google Sign-In**
+ * ```kotlin
+ * val googleCredential = GoogleAuthProvider.getCredential(idToken, null)
+ * val displayName = "John Doe" // From Google profile
+ * val photoUrl = Uri.parse("https://...") // From Google profile
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = googleCredential,
+ * displayName = displayName,
+ * photoUrl = photoUrl
+ * )
+ * // User signed in with Google AND profile updated with Google data
+ * ```
+ *
+ * **Example: Phone Auth**
+ * ```kotlin
+ * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code)
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * // User signed in with phone number
+ * ```
+ *
+ * **Example: Phone Auth with Collision (Anonymous Upgrade)**
+ * ```kotlin
+ * // User is currently anonymous, trying to link a phone number
+ * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code)
+ *
+ * try {
+ * firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Phone number already exists on another account
+ * // Account linking required - UI can show account linking screen
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ *
+ * **Example: Email Link Sign-In**
+ * ```kotlin
+ * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(
+ * email = "user@example.com",
+ * emailLink = emailLink
+ * )
+ *
+ * val result = firebaseAuthUI.signInAndLinkWithCredential(
+ * config = authUIConfig,
+ * credential = emailLinkCredential
+ * )
+ * // User signed in with email link (passwordless)
+ * ```
+ */
+internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
+ config: AuthUIConfiguration,
+ credential: AuthCredential,
+ provider: AuthProvider? = null,
+ displayName: String? = null,
+ photoUrl: Uri? = null,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in user..."))
+ return if (canUpgradeAnonymous(config, auth)) {
+ auth.currentUser?.linkWithCredential(credential)?.await()
+ } else {
+ auth.signInWithCredential(credential).await()
+ }.also { result ->
+ // Merge profile information from the provider
+ result?.user?.let {
+ mergeProfile(auth, displayName, photoUrl)
+ }
+ updateAuthState(AuthState.Idle)
+ }
+ } catch (e: FirebaseAuthMultiFactorException) {
+ // MFA required - extract resolver and update state
+ val resolver = e.resolver
+ val hint = resolver.hints.firstOrNull()?.displayName
+ updateAuthState(AuthState.RequiresMfa(resolver, hint))
+ return null
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: account already exists with different sign-in method
+ // Create AccountLinkingRequiredException with credential for linking
+ val email = e.email
+ val credentialForException = if (canUpgradeAnonymous(config, auth)) {
+ // For anonymous upgrade, use the updated credential from the exception
+ e.updatedCredential ?: credential
+ } else {
+ // For non-anonymous, use the original credential
+ credential
+ }
+
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with the email ${email ?: ""}. " +
+ "Please sign in with your existing account to link " +
+ "your ${provider?.providerName ?: "this provider"} account.",
+ email = email,
+ credential = credentialForException,
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in and link with credential was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Sends a passwordless sign-in link to the specified email address.
+ *
+ * This method initiates the email-link (passwordless) authentication flow by sending
+ * an email containing a magic link. The link includes session information for validation
+ * and security.
+ *
+ * **How it works:**
+ * 1. Generates a unique session ID for same-device validation
+ * 2. Retrieves anonymous user ID if upgrading anonymous account
+ * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag)
+ * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail]
+ * 5. Saves session data to DataStore for validation when the user clicks the link
+ * 6. User receives email with a magic link containing the session information
+ * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication
+ *
+ * **Account Linking Support:**
+ * If a user tries to sign in with a social provider (Google, Facebook) but an email link
+ * account already exists with that email, the social provider implementation should:
+ * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt
+ * 2. Call [EmailLinkPersistenceManager.default.saveCredentialForLinking] with the provider tokens
+ * 3. Call this method to send the email link
+ * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential
+ *
+ * **Session Security:**
+ * - **Session ID**: Random 10-character string for same-device validation
+ * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking
+ * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled]
+ * - All session data is validated in [signInWithEmailLink] before completing authentication
+ *
+ * @param context Android [Context] for DataStore access
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings]
+ * @param email The email address to send the sign-in link to
+ * @param credentialForLinking Optional [AuthCredential] from a social provider to link after email sign-in.
+ * If provided, the credential is saved to DataStore and automatically linked
+ * when [signInWithEmailLink] completes. Used for account linking flows.
+ *
+ * @throws AuthException.InvalidCredentialsException if email is invalid
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws IllegalStateException if ActionCodeSettings is not configured
+ *
+ * **Example 1: Basic email link sign-in**
+ * ```kotlin
+ * // Send the email link
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com"
+ * )
+ * // Show "Check your email" UI to user
+ *
+ * // Later, when user clicks the link in their email:
+ * // (In your deep link handling Activity)
+ * val emailLink = intent.data.toString()
+ * firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com",
+ * emailLink = emailLink
+ * )
+ * // User is now signed in
+ * ```
+ *
+ * **Example 2: Anonymous user upgrade**
+ * ```kotlin
+ * // User is currently signed in anonymously
+ * // Send email link to upgrade anonymous account to permanent email account
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "user@example.com"
+ * )
+ * // Session includes anonymous user ID for validation
+ * // When user clicks link, anonymous account is upgraded to permanent account
+ * ```
+ *
+ * **Example 3: Social provider linking**
+ * ```kotlin
+ * try {
+ * // Try to sign in with Google
+ * authUI.signInWithGoogle(...)
+ * } catch (e: FirebaseAuthUserCollisionException) {
+ * // Email already exists with email-link provider
+ * val googleCredential = e.updatedCredential
+ *
+ * // Save credential for linking
+ * EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ * context = context,
+ * providerType = "google.com",
+ * idToken = (googleCredential as GoogleAuthCredential).idToken,
+ * accessToken = null
+ * )
+ *
+ * // Send email link with credential
+ * firebaseAuthUI.sendSignInLinkToEmail(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = e.email!!,
+ * credentialForLinking = googleCredential
+ * )
+ * // When user clicks link and signs in, Google is automatically linked
+ * }
+ * ```
+ *
+ * @see signInWithEmailLink
+ * @see EmailLinkPersistenceManager
+ * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail
+ */
+internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ email: String,
+ credentialForLinking: AuthCredential?,
+ persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default,
+) {
+ try {
+ updateAuthState(AuthState.Loading("Sending sign in email link..."))
+
+ // Get anonymousUserId if can upgrade anonymously else default to empty string.
+ // NOTE: check for empty string instead of null to validate anonymous user ID matches
+ // when sign in from email link
+ val anonymousUserId =
+ if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid
+ ?: "") else ""
+
+ // Generate sessionId
+ val sessionId =
+ SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH)
+
+ // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same
+ // device flag
+ val updatedActionCodeSettings =
+ provider.addSessionInfoToActionCodeSettings(
+ sessionId = sessionId,
+ anonymousUserId = anonymousUserId,
+ credentialForLinking = credentialForLinking
+ )
+
+ auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await()
+
+ // Save Email to dataStore for use in signInWithEmailLink
+ persistenceManager.saveEmail(context, email, sessionId, anonymousUserId)
+
+ updateAuthState(AuthState.EmailSignInLinkSent())
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Send sign in link to email was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user using an email link (passwordless authentication).
+ *
+ * This method completes the email link sign-in flow after the user clicks the magic link
+ * sent to their email. It validates the link, extracts session information, and either
+ * signs in the user normally or upgrades an anonymous account based on configuration.
+ *
+ * **Flow:**
+ * 1. User receives email with magic link
+ * 2. User clicks link, app opens via deep link
+ * 3. Activity extracts emailLink from Intent.data
+ * 4. This method validates and completes sign-in
+ *
+ * **Same-Device Flow:**
+ * - Email is retrieved from DataStore automatically
+ * - Session ID from link matches stored session ID
+ * - User is signed in immediately without additional input
+ *
+ * **Cross-Device Flow:**
+ * - Session ID from link doesn't match (or no local session exists)
+ * - If [email] is empty: throws [AuthException.EmailLinkPromptForEmailException]
+ * - User must provide their email address
+ * - Call this method again with user-provided email to complete sign-in
+ *
+ * @param context Android [Context] for DataStore access
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Email] configuration with email-link settings
+ * @param email The email address of the user. On same-device, retrieved from DataStore.
+ * On cross-device first call, pass empty string to trigger validation.
+ * On cross-device second call, pass user-provided email.
+ * @param emailLink The complete deep link URL received from the Intent.
+ * @param persistenceManager Optional [PersistenceManager] for testing. Defaults to [EmailLinkPersistenceManager.default]
+ *
+ * This URL contains:
+ * - Firebase action code (oobCode) for authentication
+ * - Session ID (ui_sid) for same-device validation
+ * - Anonymous user ID (ui_auid) if upgrading anonymous account
+ * - Force same-device flag (ui_sd) for security enforcement
+ * - Provider ID (ui_pid) if linking social provider credential
+ *
+ * Example:
+ * `https://yourapp.page.link/__/auth/action?oobCode=ABC123&continueUrl=https://yourapp.com?ui_sid=123456&ui_auid=anon-uid`
+ *
+ * @return [AuthResult] containing the signed-in user, or null if cross-device validation is required
+ *
+ * @throws AuthException.InvalidEmailLinkException if the email link is invalid or expired
+ * @throws AuthException.EmailLinkPromptForEmailException if cross-device and email is empty
+ * @throws AuthException.EmailLinkWrongDeviceException if force same-device is enabled on different device
+ * @throws AuthException.EmailLinkCrossDeviceLinkingException if trying to link provider on different device
+ * @throws AuthException.EmailLinkDifferentAnonymousUserException if anonymous user ID doesn't match
+ * @throws AuthException.EmailMismatchException if email is empty on same-device flow
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.UnknownException for other errors
+ *
+ * **Example 1: Same-device sign-in (automatic)**
+ * ```kotlin
+ * // In your deep link handler Activity:
+ * val emailLink = intent.data.toString()
+ * val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email
+ *
+ * if (savedEmail != null) {
+ * // Same device - email and session are stored
+ * val result = firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = savedEmail,
+ * emailLink = emailLink
+ * )
+ * // User is signed in automatically
+ * }
+ * ```
+ *
+ * **Example 2: Cross-device sign-in (with email prompt)**
+ * ```kotlin
+ * // First call with empty email to validate link
+ * try {
+ * firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = "", // Empty email on different device
+ * emailLink = emailLink
+ * )
+ * } catch (e: AuthException.EmailLinkPromptForEmailException) {
+ * // Show dialog asking user to enter their email
+ * val userEmail = showEmailInputDialog()
+ *
+ * // Second call with user-provided email
+ * val result = firebaseAuthUI.signInWithEmailLink(
+ * context = context,
+ * config = authUIConfig,
+ * provider = emailProvider,
+ * email = userEmail, // User provided email
+ * emailLink = emailLink
+ * )
+ * // User is now signed in
+ * }
+ * ```
+ *
+ * @see sendSignInLinkToEmail for sending the initial email link
+ * @see EmailLinkPersistenceManager for session data management
+ */
+internal suspend fun FirebaseAuthUI.signInWithEmailLink(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Email,
+ email: String,
+ emailLink: String,
+ persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in with email link..."))
+
+ // Validate link format
+ if (!auth.isSignInWithEmailLink(emailLink)) {
+ throw AuthException.InvalidEmailLinkException()
+ }
+
+ // Parse email link for session data
+ val parser = EmailLinkParser(emailLink)
+ val sessionIdFromLink = parser.sessionId
+ val anonymousUserIdFromLink = parser.anonymousUserId
+ val oobCode = parser.oobCode
+ val providerIdFromLink = parser.providerId
+ val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit
+
+ // Retrieve stored session record from DataStore
+ val sessionRecord = persistenceManager.retrieveSessionRecord(context)
+ val storedSessionId = sessionRecord?.sessionId
+
+ // Check if this is a different device flow
+ val isDifferentDevice = provider.isDifferentDevice(
+ sessionIdFromLocal = storedSessionId,
+ sessionIdFromLink = sessionIdFromLink ?: "" // Convert null to empty string to match legacy behavior
+ )
+
+ if (isDifferentDevice) {
+ // Handle cross-device flow
+ // Session ID must always be present in the link
+ if (sessionIdFromLink.isNullOrEmpty()) {
+ val exception = AuthException.InvalidEmailLinkException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // These scenarios require same-device flow
+ if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) {
+ val exception = AuthException.EmailLinkWrongDeviceException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // If we have no SessionRecord/there is a session ID mismatch, this means that we were
+ // not the ones to send the link. The only way forward is to prompt the user for their
+ // email before continuing the flow. We should only do that after validating the link.
+ // However, if email is already provided (cross-device with user input), skip validation
+ if (email.isEmpty()) {
+ handleDifferentDeviceErrorFlow(oobCode, providerIdFromLink, emailLink)
+ return null
+ }
+ // Email provided - validate it and continue with normal flow
+ }
+
+ // Validate email is not empty (same-device flow only)
+ if (email.isEmpty()) {
+ throw AuthException.EmailMismatchException()
+ }
+
+ // Validate anonymous user ID matches (same-device flow)
+ if (!anonymousUserIdFromLink.isNullOrEmpty()) {
+ val currentUser = auth.currentUser
+ if (currentUser == null
+ || !currentUser.isAnonymous
+ || currentUser.uid != anonymousUserIdFromLink
+ ) {
+ val exception = AuthException.EmailLinkDifferentAnonymousUserException()
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+ }
+
+ // Get credential for linking from session record
+ val storedCredentialForLink = sessionRecord?.credentialForLinking
+ val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink)
+
+ val result = if (storedCredentialForLink == null) {
+ // Normal Flow: Just sign in with email link
+ handleEmailLinkNormalFlow(config, emailLinkCredential)
+ } else {
+ // Linking Flow: Sign in with email link, then link the social credential
+ handleEmailLinkCredentialLinkingFlow(
+ context = context,
+ config = config,
+ email = email,
+ emailLinkCredential = emailLinkCredential,
+ storedCredentialForLink = storedCredentialForLink,
+ )
+ }
+ // Clear DataStore after success
+ persistenceManager.clear(context)
+ updateAuthState(AuthState.Idle)
+ return result
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with email link was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow(
+ oobCode: String,
+ providerIdFromLink: String?,
+ emailLink: String
+) {
+ // Validate the action code
+ try {
+ auth.checkActionCode(oobCode).await()
+ } catch (e: Exception) {
+ // Invalid action code
+ val exception = AuthException.InvalidEmailLinkException(cause = e)
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // If there's a provider ID, this is a linking flow which can't be done cross-device
+ if (!providerIdFromLink.isNullOrEmpty()) {
+ val providerNameForMessage =
+ Provider.fromId(providerIdFromLink)?.providerName ?: providerIdFromLink
+ val exception = AuthException.EmailLinkCrossDeviceLinkingException(
+ providerName = providerNameForMessage,
+ emailLink = emailLink
+ )
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+ }
+
+ // Link is valid but we need the user to provide their email
+ val exception = AuthException.EmailLinkPromptForEmailException(
+ cause = null,
+ emailLink = emailLink
+ )
+ updateAuthState(AuthState.Error(exception))
+ throw exception
+}
+
+private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow(
+ config: AuthUIConfiguration,
+ emailLinkCredential: AuthCredential,
+): AuthResult? {
+ return signInAndLinkWithCredential(config, emailLinkCredential)
+}
+
+private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow(
+ context: Context,
+ config: AuthUIConfiguration,
+ email: String,
+ emailLinkCredential: AuthCredential,
+ storedCredentialForLink: AuthCredential,
+): AuthResult? {
+ return if (canUpgradeAnonymous(config, auth)) {
+ // Anonymous upgrade: Use safe link pattern with scratch auth
+ val appExplicitlyForValidation = FirebaseApp.initializeApp(
+ context,
+ auth.app.options,
+ "FUIAuthScratchApp_${System.currentTimeMillis()}"
+ )
+ val authExplicitlyForValidation = FirebaseAuth
+ .getInstance(appExplicitlyForValidation)
+
+ // Safe link: Validate that both credentials can be linked
+ authExplicitlyForValidation
+ .signInWithCredential(emailLinkCredential).await()
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with this email. " +
+ "Please sign in with your existing account to upgrade your anonymous account.",
+ email = email,
+ credential = storedCredentialForLink,
+ cause = null
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ }
+ } else {
+ // Non-upgrade: Sign in with email link, then link social credential
+ auth.signInWithCredential(emailLinkCredential).await()
+ // Link the social credential
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ result?.user?.let { user ->
+ // Merge profile from the linked social credential
+ mergeProfile(
+ auth,
+ user.displayName,
+ user.photoUrl
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Sends a password reset email to the specified email address.
+ *
+ * This method initiates the "forgot password" flow by sending an email to the user
+ * with a link to reset their password. The user will receive an email from Firebase
+ * containing a link that allows them to set a new password for their account.
+ *
+ * **Flow:**
+ * 1. Validate the email address exists in Firebase Auth
+ * 2. Send password reset email to the user
+ * 3. Emit [AuthState.PasswordResetLinkSent] state
+ * 4. User clicks link in email to reset password
+ * 5. User is redirected to Firebase-hosted password reset page (or custom URL if configured)
+ *
+ * **Error Handling:**
+ * - If the email doesn't exist: throws [AuthException.UserNotFoundException]
+ * - If the email is invalid: throws [AuthException.InvalidCredentialsException]
+ * - If network error occurs: throws [AuthException.NetworkException]
+ *
+ * @param email The email address to send the password reset email to
+ * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link.
+ * Use this to customize the continue URL, dynamic link domain, and other settings.
+ *
+ * @throws AuthException.UserNotFoundException if no account exists with this email
+ * @throws AuthException.InvalidCredentialsException if the email format is invalid
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.UnknownException for other errors
+ *
+ * **Example 1: Basic password reset**
+ * ```kotlin
+ * try {
+ * firebaseAuthUI.sendPasswordResetEmail(
+ * email = "user@example.com"
+ * )
+ * // Show success message: "Password reset email sent to $email"
+ * } catch (e: AuthException.UserNotFoundException) {
+ * // Show error: "No account exists with this email"
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Show error: "Invalid email address"
+ * }
+ * ```
+ *
+ * **Example 2: Custom password reset with ActionCodeSettings**
+ * ```kotlin
+ * val actionCodeSettings = ActionCodeSettings.newBuilder()
+ * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset
+ * .setHandleCodeInApp(false) // Use Firebase-hosted reset page
+ * .setAndroidPackageName(
+ * "com.myapp",
+ * true, // Install if not available
+ * null // Minimum version
+ * )
+ * .build()
+ *
+ * firebaseAuthUI.sendPasswordResetEmail(
+ * email = "user@example.com",
+ * actionCodeSettings = actionCodeSettings
+ * )
+ * // User receives email with custom continue URL
+ * ```
+ *
+ * @see com.google.firebase.auth.ActionCodeSettings
+ */
+internal suspend fun FirebaseAuthUI.sendPasswordResetEmail(
+ email: String,
+ actionCodeSettings: ActionCodeSettings? = null,
+) {
+ try {
+ updateAuthState(AuthState.Loading("Sending password reset email..."))
+ auth.sendPasswordResetEmail(email, actionCodeSettings).await()
+ updateAuthState(AuthState.PasswordResetLinkSent())
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Send password reset email was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(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
new file mode 100644
index 000000000..9939a4b47
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.content.Context
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.facebook.AccessToken
+import com.facebook.CallbackManager
+import com.facebook.FacebookCallback
+import com.facebook.FacebookException
+import com.facebook.login.LoginManager
+import com.facebook.login.LoginResult
+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.util.EmailLinkPersistenceManager
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+/**
+ * Creates a remembered launcher function for Facebook sign-in.
+ *
+ * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles
+ * profile data fetching, Firebase credential creation, anonymous account upgrades, and account
+ * linking when an email collision occurs.
+ *
+ * @param context Android context for DataStore access when saving credentials for linking
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider
+ *
+ * @return A launcher function that starts the Facebook sign-in flow when invoked
+ *
+ * @see signInWithFacebook
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Facebook,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ val callbackManager = remember { CallbackManager.Factory.create() }
+ val loginManager = LoginManager.getInstance()
+
+ val launcher = rememberLauncherForActivityResult(
+ loginManager.createLogInActivityResultContract(
+ callbackManager,
+ null
+ ),
+ onResult = {},
+ )
+
+ DisposableEffect(Unit) {
+ loginManager.registerCallback(
+ callbackManager,
+ object : FacebookCallback {
+ override fun onSuccess(result: LoginResult) {
+ coroutineScope.launch {
+ try {
+ signInWithFacebook(
+ context = context,
+ config = config,
+ provider = provider,
+ accessToken = result.accessToken,
+ )
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+
+ override fun onCancel() {
+ updateAuthState(AuthState.Idle)
+ }
+
+ override fun onError(error: FacebookException) {
+ Log.e("FacebookAuthProvider", "Error during Facebook sign in", error)
+ val authException = AuthException.from(error)
+ updateAuthState(
+ AuthState.Error(
+ authException
+ )
+ )
+ }
+ })
+
+ onDispose { loginManager.unregisterCallback(callbackManager) }
+ }
+
+ return {
+ updateAuthState(
+ AuthState.Loading("Signing in with facebook...")
+ )
+ launcher.launch(provider.scopes)
+ }
+}
+
+/**
+ * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential.
+ *
+ * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in
+ * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential
+ * for linking and throwing [AuthException.AccountLinkingRequiredException].
+ *
+ * @param context Android context for DataStore access when saving credentials for linking
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param provider The [AuthProvider.Facebook] configuration
+ * @param accessToken The Facebook [AccessToken] from successful login
+ * @param credentialProvider Creates Firebase credentials from Facebook tokens
+ *
+ * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email
+ * @throws AuthException.AuthCancelledException if the coroutine is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid
+ *
+ * @see rememberSignInWithFacebookLauncher
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithFacebook(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Facebook,
+ accessToken: AccessToken,
+ credentialProvider: AuthProvider.Facebook.CredentialProvider = AuthProvider.Facebook.DefaultCredentialProvider(),
+) {
+ try {
+ updateAuthState(
+ AuthState.Loading("Signing in with facebook...")
+ )
+ val profileData = provider.fetchFacebookProfile(accessToken)
+ val credential = credentialProvider.getCredential(accessToken.token)
+ signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ provider = provider,
+ displayName = profileData?.displayName,
+ photoUrl = profileData?.photoUrl,
+ )
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ // Account collision occurred - save Facebook credential for linking after email link sign-in
+ // This happens when a user tries to sign in with Facebook but an email link account exists
+ EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ context = context,
+ providerType = provider.providerId,
+ idToken = null,
+ accessToken = accessToken.token
+ )
+
+ // Re-throw to let UI handle the account linking flow
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: FacebookException) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with facebook was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(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
new file mode 100644
index 000000000..d42362692
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,210 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+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.util.EmailLinkPersistenceManager
+import com.google.android.gms.common.api.Scope
+import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+/**
+ * Creates a remembered callback for Google Sign-In that can be invoked from UI components.
+ *
+ * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In
+ * flow using [signInWithGoogle]. The callback is stable across recompositions and automatically
+ * handles coroutine scoping and error state management.
+ *
+ * **Usage:**
+ * ```kotlin
+ * val onSignInWithGoogle = authUI.rememberGoogleSignInHandler(
+ * context = context,
+ * config = configuration,
+ * provider = googleProvider
+ * )
+ *
+ * Button(onClick = onSignInWithGoogle) {
+ * Text("Sign in with Google")
+ * }
+ * ```
+ *
+ * **Error Handling:**
+ * - Catches all exceptions and converts them to [AuthException]
+ * - Automatically updates [AuthState.Error] on failures
+ * - Logs errors for debugging purposes
+ *
+ * @param context Android context for Credential Manager
+ * @param config Authentication UI configuration
+ * @param provider Google provider configuration with server client ID and optional scopes
+ * @return A callback function that initiates Google Sign-In when invoked
+ *
+ * @see signInWithGoogle
+ * @see AuthProvider.Google
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Google,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ return remember(this) {
+ {
+ coroutineScope.launch {
+ try {
+ signInWithGoogle(context, config, provider)
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in with Google using Credential Manager and optionally requests OAuth scopes.
+ *
+ * This function implements Google Sign-In using Android's Credential Manager API with
+ * comprehensive error handling.
+ *
+ * **Flow:**
+ * 1. If [AuthProvider.Google.scopes] are specified, requests OAuth authorization first
+ * 2. Attempts sign-in using Credential Manager
+ * 3. Creates Firebase credential and calls [signInAndLinkWithCredential]
+ *
+ * **Scopes Behavior:**
+ * - If [AuthProvider.Google.scopes] is not empty, requests OAuth authorization before sign-in
+ * - Basic profile, email, and ID token are always included automatically
+ * - Scopes are requested using the AuthorizationClient API
+ *
+ * **Error Handling:**
+ * - [GoogleIdTokenParsingException]: Library version mismatch
+ * - [NoCredentialException]: No Google accounts on device
+ * - [GetCredentialException]: User cancellation, configuration errors, or no credentials
+ * - Configuration errors trigger detailed developer guidance logs
+ *
+ * @param context Android context for Credential Manager
+ * @param config Authentication UI configuration
+ * @param provider Google provider configuration with optional scopes
+ * @param authorizationProvider Provider for OAuth scopes authorization (for testing)
+ * @param credentialManagerProvider Provider for Credential Manager flow (for testing)
+ *
+ * @throws AuthException.InvalidCredentialsException if token parsing fails
+ * @throws AuthException.AuthCancelledException if user cancels or no accounts found
+ * @throws AuthException if sign-in or linking fails
+ *
+ * @see AuthProvider.Google
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithGoogle(
+ context: Context,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.Google,
+ authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(),
+ credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(),
+) {
+ var idTokenFromResult: String? = null
+ try {
+ updateAuthState(AuthState.Loading("Signing in with google..."))
+
+ // Request OAuth scopes if specified (before sign-in)
+ if (provider.scopes.isNotEmpty()) {
+ try {
+ val requestedScopes = provider.scopes.map { Scope(it) }
+ authorizationProvider.authorize(context, requestedScopes)
+ } catch (e: Exception) {
+ // Continue with sign-in even if scope authorization fails
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+
+ val result =
+ (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential(
+ context = context,
+ credentialManager = CredentialManager.create(context),
+ serverClientId = provider.serverClientId!!,
+ filterByAuthorizedAccounts = true,
+ autoSelectEnabled = false
+ )
+ idTokenFromResult = result.idToken
+
+ signInAndLinkWithCredential(
+ config = config,
+ credential = result.credential,
+ provider = provider,
+ displayName = result.displayName,
+ photoUrl = result.photoUrl,
+ )
+ } catch (e: AuthException.AccountLinkingRequiredException) {
+ // Account collision occurred - save Facebook credential for linking after email link sign-in
+ // This happens when a user tries to sign in with Facebook but an email link account exists
+ EmailLinkPersistenceManager.default.saveCredentialForLinking(
+ context = context,
+ providerType = provider.providerId,
+ idToken = idTokenFromResult,
+ accessToken = null
+ )
+
+ // Re-throw to let UI handle the account linking flow
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with google was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs out from Google and clears credential state.
+ *
+ * This function clears the cached Google credentials, ensuring that the account picker
+ * will be shown on the next sign-in attempt instead of automatically signing in with
+ * the previously used account.
+ *
+ * **When to call:**
+ * - After user explicitly signs out
+ * - Before allowing user to select a different Google account
+ * - When switching between accounts
+ *
+ * **Note:** This does not sign out from Firebase Auth itself. Call [FirebaseAuthUI.signOut]
+ * separately if you need to sign out from Firebase.
+ *
+ * @param context Android context for Credential Manager
+ */
+internal suspend fun signOutFromGoogle(context: Context) {
+ try {
+ val credentialManager = CredentialManager.create(context)
+ credentialManager.clearCredentialState(
+ ClearCredentialStateRequest()
+ )
+ } catch (_: Exception) {
+
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..615aa6982
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,215 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.app.Activity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
+import com.google.firebase.auth.FirebaseAuthUserCollisionException
+import com.google.firebase.auth.OAuthCredential
+import com.google.firebase.auth.OAuthProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Creates a Composable handler for OAuth provider sign-in.
+ *
+ * This function creates a remember-scoped sign-in handler that can be invoked
+ * from button clicks or other UI events. It automatically handles:
+ * - Activity retrieval from LocalActivity
+ * - Coroutine scope management
+ * - Error handling and state updates
+ *
+ * **Usage:**
+ * ```kotlin
+ * val onSignInWithGitHub = authUI.rememberOAuthSignInHandler(
+ * config = configuration,
+ * provider = githubProvider
+ * )
+ *
+ * Button(onClick = onSignInWithGitHub) {
+ * Text("Sign in with GitHub")
+ * }
+ * ```
+ *
+ * @param config Authentication UI configuration
+ * @param provider OAuth provider configuration
+ *
+ * @return Lambda that triggers OAuth sign-in when invoked
+ *
+ * @throws IllegalStateException if LocalActivity.current is null
+ *
+ * @see signInWithProvider
+ */
+@Composable
+internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
+ activity: Activity?,
+ config: AuthUIConfiguration,
+ provider: AuthProvider.OAuth,
+): () -> Unit {
+ val coroutineScope = rememberCoroutineScope()
+ activity ?: throw IllegalStateException(
+ "OAuth sign-in requires an Activity. " +
+ "Ensure FirebaseAuthScreen is used within an Activity."
+ )
+
+ return remember(this, provider.providerId) {
+ {
+ coroutineScope.launch {
+ try {
+ signInWithProvider(
+ config = config,
+ activity = activity,
+ provider = provider
+ )
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Signs in with an OAuth provider (GitHub, Microsoft, Yahoo, Apple, Twitter).
+ *
+ * This function implements OAuth provider authentication using Firebase's native OAuthProvider.
+ * It handles both normal sign-in flow and anonymous user upgrade flow.
+ *
+ * **Supported Providers:**
+ * - GitHub (github.com)
+ * - Microsoft (microsoft.com)
+ * - Yahoo (yahoo.com)
+ * - Apple (apple.com)
+ * - Twitter (twitter.com)
+ *
+ * **Flow:**
+ * 1. Checks for pending auth results (e.g., from app restart during OAuth flow)
+ * 2. If anonymous upgrade is enabled and user is anonymous, links credential to anonymous account
+ * 3. Otherwise, performs normal sign-in
+ * 4. Updates auth state to Idle on success
+ *
+ * **Anonymous Upgrade:**
+ * If [AuthUIConfiguration.isAnonymousUpgradeEnabled] is true and a user is currently signed in
+ * anonymously, this will attempt to link the OAuth credential to the anonymous account instead
+ * of creating a new account.
+ *
+ * **Error Handling:**
+ * - [AuthException.AuthCancelledException]: User cancelled OAuth flow
+ * - [AuthException.AccountLinkingRequiredException]: Account collision (email already exists)
+ * - [AuthException]: Other authentication errors
+ *
+ * @param config Authentication UI configuration
+ * @param activity Activity for OAuth flow
+ * @param provider OAuth provider configuration with scopes and custom parameters
+ *
+ * @throws AuthException.AuthCancelledException if user cancels
+ * @throws AuthException.AccountLinkingRequiredException if account collision occurs
+ * @throws AuthException if OAuth flow or sign-in fails
+ *
+ * @see AuthProvider.OAuth
+ * @see signInAndLinkWithCredential
+ */
+internal suspend fun FirebaseAuthUI.signInWithProvider(
+ config: AuthUIConfiguration,
+ activity: Activity,
+ provider: AuthProvider.OAuth,
+) {
+ try {
+ updateAuthState(AuthState.Loading("Signing in with ${provider.providerName}..."))
+
+ // Build OAuth provider with scopes and custom parameters
+ val oauthProvider = OAuthProvider
+ .newBuilder(provider.providerId)
+ .apply {
+ // Add scopes if provided
+ if (provider.scopes.isNotEmpty()) {
+ scopes = provider.scopes
+ }
+ // Add custom parameters if provided
+ provider.customParameters.forEach { (key, value) ->
+ addCustomParameter(key, value)
+ }
+ }
+ .build()
+
+ // Check for pending auth result (e.g., app was killed during OAuth flow)
+ val pendingResult = auth.pendingAuthResult
+ if (pendingResult != null) {
+ val authResult = pendingResult.await()
+ val credential = authResult.credential as? OAuthCredential
+
+ if (credential != null) {
+ // Complete the pending sign-in/link flow
+ signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ provider = provider,
+ displayName = authResult.user?.displayName,
+ photoUrl = authResult.user?.photoUrl,
+ )
+ }
+ updateAuthState(AuthState.Idle)
+ return
+ }
+
+ // Determine if we should upgrade anonymous user or do normal sign-in
+ val authResult = if (canUpgradeAnonymous(config, auth)) {
+ auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await()
+ } else {
+ auth.startActivityForSignInWithProvider(activity, oauthProvider).await()
+ }
+
+ // Extract OAuth credential and complete sign-in
+ val credential = authResult?.credential as? OAuthCredential
+ if (credential != null) {
+ // The user is already signed in via startActivityForSignInWithProvider/startActivityForLinkWithProvider
+ // Just update state to Idle
+ updateAuthState(AuthState.Idle)
+ } else {
+ throw AuthException.UnknownException(
+ message = "OAuth sign-in did not return a valid credential"
+ )
+ }
+
+ } catch (e: FirebaseAuthUserCollisionException) {
+ // Account collision: account already exists with different sign-in method
+ val email = e.email
+ val credential = e.updatedCredential
+
+ val accountLinkingException = AuthException.AccountLinkingRequiredException(
+ message = "An account already exists with the email ${email ?: ""}. " +
+ "Please sign in with your existing account to link " +
+ "your ${provider.providerName} account.",
+ email = email,
+ credential = credential,
+ cause = e
+ )
+ updateAuthState(AuthState.Error(accountLinkingException))
+ throw accountLinkingException
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Signing in with ${provider.providerName} was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(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
new file mode 100644
index 000000000..95dbdcf78
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt
@@ -0,0 +1,315 @@
+package com.firebase.ui.auth.configuration.auth_provider
+
+import android.app.Activity
+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.google.firebase.auth.AuthResult
+import com.google.firebase.auth.MultiFactorSession
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthProvider
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Initiates phone number verification with Firebase Phone Authentication.
+ *
+ * This method starts the phone verification flow, which can complete in two ways:
+ * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies
+ * the SMS code without user interaction. This happens when Google Play services can
+ * detect the incoming SMS automatically.
+ * 2. **Manual verification**: SMS code is sent to the user's device, and the user must
+ * manually enter the code via [submitVerificationCode].
+ *
+ * **Flow:**
+ * - Call this method with the phone number
+ * - Firebase SDK attempts instant verification
+ * - If instant verification succeeds:
+ * - Emits [AuthState.SMSAutoVerified] with the credential
+ * - UI should observe this state and call [signInWithPhoneAuthCredential]
+ * - If instant verification fails:
+ * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details
+ * - UI should show code entry screen
+ * - User enters code → call [submitVerificationCode]
+ *
+ * **Resending codes:**
+ * To resend a verification code, call this method again with:
+ * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired]
+ *
+ * **Example: Basic phone verification**
+ * ```kotlin
+ * // Step 1: Start verification
+ * firebaseAuthUI.verifyPhoneNumber(
+ * provider = phoneProvider,
+ * phoneNumber = "+1234567890",
+ * )
+ *
+ * // Step 2: Observe AuthState
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.SMSAutoVerified -> {
+ * // Instant verification succeeded!
+ * showToast("Phone number verified automatically")
+ * // Now sign in with the credential
+ * firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = state.credential
+ * )
+ * }
+ * is AuthState.PhoneNumberVerificationRequired -> {
+ * // Show code entry screen
+ * showCodeEntryScreen(
+ * verificationId = state.verificationId,
+ * forceResendingToken = state.forceResendingToken
+ * )
+ * }
+ * is AuthState.Error -> {
+ * // Handle error
+ * showError(state.exception.message)
+ * }
+ * }
+ * }
+ *
+ * // Step 3: When user enters code
+ * firebaseAuthUI.submitVerificationCode(
+ * config = authUIConfig,
+ * verificationId = verificationId,
+ * code = userEnteredCode
+ * )
+ * ```
+ *
+ * **Example: Resending verification code**
+ * ```kotlin
+ * // User didn't receive the code, wants to resend
+ * firebaseAuthUI.verifyPhoneNumber(
+ * provider = phoneProvider,
+ * phoneNumber = "+1234567890",
+ * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state
+ * )
+ * ```
+ *
+ * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings
+ * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890")
+ * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided,
+ * this initiates phone verification for enrolling a second factor rather than primary sign-in.
+ * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA.
+ * @param forceResendingToken Optional token from previous verification for resending SMS
+ *
+ * @throws AuthException.InvalidCredentialsException if the phone number is invalid
+ * @throws AuthException.TooManyRequestsException if SMS quota is exceeded
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.verifyPhoneNumber(
+ provider: AuthProvider.Phone,
+ activity: Activity?,
+ phoneNumber: String,
+ multiFactorSession: MultiFactorSession? = null,
+ forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null,
+ verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(),
+) {
+ try {
+ updateAuthState(AuthState.Loading("Verifying phone number..."))
+ val result = provider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = forceResendingToken,
+ verifier = verifier
+ )
+ when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ updateAuthState(AuthState.SMSAutoVerified(credential = result.credential))
+ }
+
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ updateAuthState(
+ AuthState.PhoneNumberVerificationRequired(
+ verificationId = result.verificationId,
+ forceResendingToken = result.token,
+ )
+ )
+ }
+ }
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Verify phone number was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Submits a verification code entered by the user and signs them in.
+ *
+ * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired],
+ * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the
+ * verification ID and user-entered code, then signs in the user by calling
+ * [signInWithPhoneAuthCredential].
+ *
+ * **Flow:**
+ * 1. User receives SMS with 6-digit code
+ * 2. User enters code in UI
+ * 3. UI calls this method with the code
+ * 4. Credential is created and used to sign in
+ * 5. Returns [AuthResult] with signed-in user
+ *
+ * This method handles both normal sign-in and anonymous account upgrade scenarios based
+ * on the [AuthUIConfiguration] settings.
+ *
+ * **Example: Manual code entry flow*
+ * ```
+ * val userEnteredCode = "123456"
+ * try {
+ * val result = firebaseAuthUI.submitVerificationCode(
+ * config = authUIConfig,
+ * verificationId = savedVerificationId!!,
+ * code = userEnteredCode
+ * )
+ * // User is now signed in
+ * } catch (e: AuthException.InvalidCredentialsException) {
+ * // Wrong code entered
+ * showError("Invalid verification code")
+ * } catch (e: AuthException.SessionExpiredException) {
+ * // Code expired
+ * showError("Verification code expired. Please request a new one.")
+ * }
+ * ```
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired]
+ * @param code The 6-digit verification code entered by the user
+ *
+ * @return [AuthResult] containing the signed-in user
+ *
+ * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.submitVerificationCode(
+ config: AuthUIConfiguration,
+ verificationId: String,
+ code: String,
+ credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(),
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Submitting verification code..."))
+ val credential = credentialProvider.getCredential(verificationId, code)
+ return signInWithPhoneAuthCredential(
+ config = config,
+ credential = credential
+ )
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Submit verification code was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
+
+/**
+ * Signs in a user with a phone authentication credential.
+ *
+ * This method is the final step in the phone authentication flow. It takes a
+ * [PhoneAuthCredential] (either from instant verification or manual code entry) and
+ * signs in the user. The method handles both normal sign-in and anonymous account
+ * upgrade scenarios by delegating to [signInAndLinkWithCredential].
+ *
+ * **When to call this:**
+ * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification)
+ * - Called internally by [submitVerificationCode] (manual verification)
+ *
+ * The method automatically handles:
+ * - Normal sign-in for new or returning users
+ * - Linking phone credential to anonymous accounts (if enabled in config)
+ * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account
+ *
+ * **Example: Sign in after instant verification**
+ * ```kotlin
+ * authUI.authStateFlow().collect { state ->
+ * when (state) {
+ * is AuthState.SMSAutoVerified -> {
+ * // Phone was instantly verified
+ * showToast("Phone verified automatically!")
+ *
+ * // Now sign in with the credential
+ * val result = firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = state.credential
+ * )
+ * // User is now signed in
+ * }
+ * }
+ * }
+ * ```
+ *
+ * **Example: Anonymous upgrade with collision**
+ * ```kotlin
+ * // User is currently anonymous
+ * try {
+ * firebaseAuthUI.signInWithPhoneAuthCredential(
+ * config = authUIConfig,
+ * credential = phoneCredential
+ * )
+ * } catch (e: AuthException.AccountLinkingRequiredException) {
+ * // Phone number already exists on another account
+ * // Account linking required - show account linking screen
+ * // User needs to sign in with existing account to link
+ * }
+ * ```
+ *
+ * @param config The [AuthUIConfiguration] containing authentication settings
+ * @param credential The [PhoneAuthCredential] to use for signing in
+ *
+ * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred
+ *
+ * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired
+ * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
+ * @throws AuthException.NetworkException if a network error occurs
+ */
+internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential(
+ config: AuthUIConfiguration,
+ credential: PhoneAuthCredential,
+): AuthResult? {
+ try {
+ updateAuthState(AuthState.Loading("Signing in with phone..."))
+ return signInAndLinkWithCredential(
+ config = config,
+ credential = credential,
+ )
+ } catch (e: CancellationException) {
+ val cancelledException = AuthException.AuthCancelledException(
+ message = "Sign in with phone was cancelled",
+ cause = e
+ )
+ updateAuthState(AuthState.Error(cancelledException))
+ throw cancelledException
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ throw e
+ } catch (e: Exception) {
+ val authException = AuthException.from(e)
+ updateAuthState(AuthState.Error(authException))
+ throw authException
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
new file mode 100644
index 000000000..f00cffee6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProvider.kt
@@ -0,0 +1,515 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.string_provider
+
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/**
+ * CompositionLocal for providing [AuthUIStringProvider] throughout the Compose tree.
+ *
+ * This allows accessing localized strings without manually passing the provider through
+ * every composable. The provider is set at the top level in FirebaseAuthScreen and can
+ * be accessed anywhere in the auth UI using `LocalAuthUIStringProvider.current`.
+ *
+ * **Usage:**
+ * ```kotlin
+ * @Composable
+ * fun MyAuthComponent() {
+ * val stringProvider = LocalAuthUIStringProvider.current
+ * Text(stringProvider.signInWithGoogle)
+ * }
+ * ```
+ *
+ * @since 10.0.0
+ */
+val LocalAuthUIStringProvider = staticCompositionLocalOf {
+ error("No AuthUIStringProvider provided. Ensure FirebaseAuthScreen is used as the root composable.")
+}
+
+/**
+ * An interface for providing localized string resources. This interface defines methods for all
+ * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmailAddress(),
+ * passwordsDoNotMatch(), etc., allowing for complete localization of the UI.
+ *
+ * @sample AuthUIStringProviderSample
+ */
+interface AuthUIStringProvider {
+ /** Loading text displayed during initialization or processing states */
+ val initializing: String
+
+ /** Text for Google Provider */
+ val googleProvider: String
+
+ /** Text for Facebook Provider */
+ val facebookProvider: String
+
+ /** Text for Twitter Provider */
+ val twitterProvider: String
+
+ /** Text for Github Provider */
+ val githubProvider: String
+
+ /** Text for Phone Provider */
+ val phoneProvider: String
+
+ /** Text for Email Provider */
+ val emailProvider: String
+
+ /** Button text for Google sign-in option */
+ val signInWithGoogle: String
+
+ /** Button text for Facebook sign-in option */
+ val signInWithFacebook: String
+
+ /** Button text for Twitter sign-in option */
+ val signInWithTwitter: String
+
+ /** Button text for Github sign-in option */
+ val signInWithGithub: String
+
+ /** Button text for Email sign-in option */
+ val signInWithEmail: String
+
+ /** Button text for Phone sign-in option */
+ val signInWithPhone: String
+
+ /** Button text for Anonymous sign-in option */
+ val signInAnonymously: String
+
+ /** Button text for Apple sign-in option */
+ val signInWithApple: String
+
+ /** Button text for Microsoft sign-in option */
+ val signInWithMicrosoft: String
+
+ /** Button text for Yahoo sign-in option */
+ val signInWithYahoo: String
+
+ /** Error message when email address field is empty */
+ val missingEmailAddress: String
+
+ /** Error message when email address format is invalid */
+ val invalidEmailAddress: String
+
+ /** Generic error message for incorrect password during sign-in */
+ val invalidPassword: String
+
+ /** Error message when password confirmation doesn't match the original password */
+ val passwordsDoNotMatch: String
+
+ /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */
+ fun passwordTooShort(minimumLength: Int): String
+
+ /** Error message when password is missing at least one uppercase letter (A-Z) */
+ val passwordMissingUppercase: String
+
+ /** Error message when password is missing at least one lowercase letter (a-z) */
+ val passwordMissingLowercase: String
+
+ /** Error message when password is missing at least one numeric digit (0-9) */
+ val passwordMissingDigit: String
+
+ /** Error message when password is missing at least one special character */
+ val passwordMissingSpecialCharacter: String
+
+ // Email Authentication Strings
+ /** Title for email signup form */
+ val signupPageTitle: String
+
+ /** Hint for email input field */
+ val emailHint: String
+
+ /** Hint for password input field */
+ val passwordHint: String
+
+ /** Hint for confirm password input field */
+ val confirmPasswordHint: String
+
+ /** Hint for new password input field */
+ val newPasswordHint: String
+
+ /** Hint for name input field */
+ val nameHint: String
+
+ /** Button text to save form */
+ val buttonTextSave: String
+
+ /** Welcome back header for email users */
+ val welcomeBackEmailHeader: String
+
+ /** Trouble signing in link text */
+ val troubleSigningIn: String
+
+ /** Title for recover password page */
+ val recoverPasswordPageTitle: String
+
+ /** Button text for reset password */
+ val sendButtonText: String
+
+ /** Title for recover password link sent dialog */
+ val recoverPasswordLinkSentDialogTitle: String
+
+ /** Body for recover password link sent dialog */
+ fun recoverPasswordLinkSentDialogBody(email: String): String
+
+ /** Title for email sign in link sent dialog */
+ val emailSignInLinkSentDialogTitle: String
+
+ /** Body for email sign in link sent dialog */
+ fun emailSignInLinkSentDialogBody(email: String): String
+
+ /** Divider text for alternate sign-in options */
+ val orContinueWith: String
+
+ /** Button text to sign in with email link */
+ val signInWithEmailLink: String
+
+ /** Button text to sign in with password */
+ val signInWithPassword: String
+
+ /** Title shown when prompting the user to confirm their email for cross-device flows */
+ val emailLinkPromptForEmailTitle: String
+
+ /** Message shown when prompting the user to confirm their email for cross-device flows */
+ val emailLinkPromptForEmailMessage: String
+
+ /** Title shown when email link must be opened on same device */
+ val emailLinkWrongDeviceTitle: String
+
+ /** Message shown when email link must be opened on same device */
+ val emailLinkWrongDeviceMessage: String
+
+ /** Title shown when the anonymous session differs */
+ val emailLinkDifferentAnonymousUserTitle: String
+
+ /** Message shown when the anonymous session differs */
+ val emailLinkDifferentAnonymousUserMessage: String
+
+ /** Message shown for cross-device linking flows with the provider name */
+ fun emailLinkCrossDeviceLinkingMessage(providerName: String): String
+
+ /** Title shown when email link is invalid */
+ val emailLinkInvalidLinkTitle: String
+
+ /** Message shown when email link is invalid */
+ val emailLinkInvalidLinkMessage: String
+
+ /** Message shown when email mismatch occurs */
+ val emailMismatchMessage: String
+
+ // Phone Authentication Strings
+ /** Phone number entry form title */
+ val verifyPhoneNumberTitle: String
+
+ /** Hint for phone input field */
+ val phoneHint: String
+
+ /** Hint for country input field */
+ val countryHint: String
+
+ /** Invalid phone number error */
+ val invalidPhoneNumber: String
+
+ /** Missing phone number error */
+ val missingPhoneNumber: String
+
+ /** Phone verification code entry form title */
+ val enterConfirmationCode: String
+
+ /** Button text to verify phone number */
+ val verifyPhoneNumber: String
+
+ /** Resend code countdown timer */
+ val resendCodeIn: String
+
+ /** Resend code link text */
+ val resendCode: String
+
+ /** Resend code with timer */
+ fun resendCodeTimer(timeFormatted: String): String
+
+ /** Verifying progress text */
+ val verifying: String
+
+ /** Wrong verification code error */
+ val incorrectCodeDialogBody: String
+
+ /** SMS terms of service warning */
+ val smsTermsOfService: String
+
+ /** Enter phone number title */
+ val enterPhoneNumberTitle: String
+
+ /** Phone number hint */
+ val phoneNumberHint: String
+
+ /** Send verification code button text */
+ val sendVerificationCode: String
+
+ /** Enter verification code title with phone number */
+ fun enterVerificationCodeTitle(phoneNumber: String): String
+
+ /** Verification code hint */
+ val verificationCodeHint: String
+
+ /** Change phone number link text */
+ val changePhoneNumber: String
+
+ /** Missing verification code error */
+ val missingVerificationCode: String
+
+ /** Invalid verification code error */
+ val invalidVerificationCode: String
+
+ /** Select country modal sheet title */
+ val countrySelectorModalTitle: String
+
+ /** Select country modal sheet input field hint */
+ val searchCountriesHint: String
+
+ // Provider Picker Strings
+ /** Common button text for sign in */
+ val signInDefault: String
+
+ /** Common button text for continue */
+ val continueText: String
+
+ /** Common button text for next */
+ val nextDefault: String
+
+ // General Error Messages
+ /** General unknown error message */
+ val errorUnknown: String
+
+ /** Required field error */
+ val requiredField: String
+
+ /** Loading progress text */
+ val progressDialogLoading: String
+
+ /** Label shown when the user is signed in. String should contain a single %s placeholder. */
+ fun signedInAs(userIdentifier: String): String
+
+ /** Action text for managing multi-factor authentication settings. */
+ val manageMfaAction: String
+
+ /** Action text for signing out. */
+ val signOutAction: String
+
+ /** Instruction shown when the user must verify their email. Accepts the email value. */
+ fun verifyEmailInstruction(email: String): String
+
+ /** Action text for resending the verification email. */
+ val resendVerificationEmailAction: String
+
+ /** Action text once the user has verified their email. */
+ val verifiedEmailAction: String
+
+ /** Message shown when profile completion is required. */
+ val profileCompletionMessage: String
+
+ /** Message listing missing profile fields. Accepts a comma-separated list. */
+ fun profileMissingFieldsMessage(fields: String): String
+
+ /** Action text for skipping an optional step. */
+ val skipAction: String
+
+ /** Action text for removing an item (for example, an MFA factor). */
+ val removeAction: String
+
+ /** Action text for navigating back. */
+ val backAction: String
+
+ /** Action text for confirming verification. */
+ val verifyAction: String
+
+ /** Action text for choosing a different factor during MFA challenge. */
+ val useDifferentMethodAction: String
+
+ /** Action text for confirming recovery codes have been saved. */
+ val recoveryCodesSavedAction: String
+
+ /** Label for secret key text displayed during TOTP setup. */
+ val secretKeyLabel: String
+
+ /** Label for verification code input fields. */
+ val verificationCodeLabel: String
+
+ /** Generic identity verified confirmation message. */
+ val identityVerifiedMessage: String
+
+ /** Title for the manage MFA screen. */
+ val mfaManageFactorsTitle: String
+
+ /** Helper description for the manage MFA screen. */
+ val mfaManageFactorsDescription: String
+
+ /** Header for the list of currently enrolled MFA factors. */
+ val mfaActiveMethodsTitle: String
+
+ /** Header for the list of available MFA factors to enroll. */
+ val mfaAddNewMethodTitle: String
+
+ /** Message shown when all factors are already enrolled. */
+ val mfaAllMethodsEnrolledMessage: String
+
+ /** Label for SMS MFA factor. */
+ val smsAuthenticationLabel: String
+
+ /** Label for authenticator-app MFA factor. */
+ val totpAuthenticationLabel: String
+
+ /** Label used when the factor type is unknown. */
+ val unknownMethodLabel: String
+
+ /** Label describing the enrollment date. Accepts a formatted date string. */
+ fun enrolledOnDateLabel(date: String): String
+
+ /** Description displayed during authenticator app setup. */
+ val setupAuthenticatorDescription: String
+
+ /** Network error message */
+ val noInternet: String
+
+ /** TOTP Code prompt */
+ val enterTOTPCode: String
+
+ // Error Recovery Dialog Strings
+ /** Error dialog title */
+ val errorDialogTitle: String
+
+ /** Retry action button text */
+ val retryAction: String
+
+ /** Dismiss action button text */
+ val dismissAction: String
+
+ /** Network error recovery message */
+ val networkErrorRecoveryMessage: String
+
+ /** Invalid credentials recovery message */
+ val invalidCredentialsRecoveryMessage: String
+
+ /** User not found recovery message */
+ val userNotFoundRecoveryMessage: String
+
+ /** Weak password recovery message */
+ val weakPasswordRecoveryMessage: String
+
+ /** Email already in use recovery message */
+ val emailAlreadyInUseRecoveryMessage: String
+
+ /** Too many requests recovery message */
+ val tooManyRequestsRecoveryMessage: String
+
+ /** MFA required recovery message */
+ val mfaRequiredRecoveryMessage: String
+
+ /** Account linking required recovery message */
+ val accountLinkingRequiredRecoveryMessage: String
+
+ /** Auth cancelled recovery message */
+ val authCancelledRecoveryMessage: String
+
+ /** Unknown error recovery message */
+ val unknownErrorRecoveryMessage: String
+
+ // MFA Enrollment Step Titles
+ /** Title for MFA factor selection step */
+ val mfaStepSelectFactorTitle: String
+
+ /** Title for SMS MFA configuration step */
+ val mfaStepConfigureSmsTitle: String
+
+ /** Title for TOTP MFA configuration step */
+ val mfaStepConfigureTotpTitle: String
+
+ /** Title for MFA verification step */
+ val mfaStepVerifyFactorTitle: String
+
+ /** Title for recovery codes step */
+ val mfaStepShowRecoveryCodesTitle: String
+
+ // MFA Enrollment Helper Text
+ /** Helper text for selecting MFA factor */
+ val mfaStepSelectFactorHelper: String
+
+ /** Helper text for SMS configuration */
+ val mfaStepConfigureSmsHelper: String
+
+ /** Helper text for TOTP configuration */
+ val mfaStepConfigureTotpHelper: String
+
+ /** Helper text for SMS verification */
+ val mfaStepVerifyFactorSmsHelper: String
+
+ /** Helper text for TOTP verification */
+ val mfaStepVerifyFactorTotpHelper: String
+
+ /** Generic helper text for factor verification */
+ val mfaStepVerifyFactorGenericHelper: String
+
+ /** Helper text for recovery codes */
+ val mfaStepShowRecoveryCodesHelper: String
+
+ // MFA Enrollment Screen Titles
+ /** Title for MFA phone number enrollment screen (top app bar) */
+ val mfaEnrollmentEnterPhoneNumber: String
+
+ /** Title for MFA SMS verification screen (top app bar) */
+ val mfaEnrollmentVerifySmsCode: String
+
+ // MFA Error Messages
+ /** Error message when MFA enrollment requires recent authentication */
+ val mfaErrorRecentLoginRequired: String
+
+ /** Error message when MFA enrollment fails due to invalid verification code */
+ val mfaErrorInvalidVerificationCode: String
+
+ /** Error message when MFA enrollment fails due to network issues */
+ val mfaErrorNetwork: String
+
+ /** Generic error message for MFA enrollment failures */
+ val mfaErrorGeneric: String
+
+ // Re-authentication Dialog
+ /** Title displayed in the re-authentication dialog. */
+ val reauthDialogTitle: String
+
+ /** Descriptive message shown in the re-authentication dialog. */
+ val reauthDialogMessage: String
+
+ /** Label showing the account email being re-authenticated. */
+ fun reauthAccountLabel(email: String): String
+
+ /** Error message shown when the provided password is incorrect. */
+ val incorrectPasswordError: String
+
+ /** General error message for re-authentication failures. */
+ val reauthGenericError: String
+
+ // Terms of Service and Privacy Policy
+ /** Terms of Service link text */
+ val termsOfService: String
+
+ /** Privacy Policy link text */
+ val privacyPolicy: String
+
+ /** ToS and Privacy Policy combined message with placeholders for links */
+ fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String
+
+ /** Tooltip message shown when new account sign-up is disabled */
+ val newAccountsDisabledTooltip: String
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt
new file mode 100644
index 000000000..cb82bee99
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/AuthUIStringProviderSample.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.string_provider
+
+import android.content.Context
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.configuration.authUIConfiguration
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+
+class AuthUIStringProviderSample {
+ /**
+ * Override specific strings while delegating others to default provider
+ */
+ class CustomAuthUIStringProvider(
+ private val defaultProvider: AuthUIStringProvider
+ ) : AuthUIStringProvider by defaultProvider {
+
+ // Override only the strings you want to customize
+ override val signInWithGoogle: String = "Continue with Google • MyApp"
+ override val signInWithFacebook: String = "Continue with Facebook • MyApp"
+
+ // Add custom branding to common actions
+ override val continueText: String = "Continue to MyApp"
+ override val signInDefault: String = "Sign in to MyApp"
+
+ // Custom MFA messaging
+ override val enterTOTPCode: String =
+ "Enter the 6-digit code from your authenticator app to secure your MyApp account"
+ }
+
+ fun createCustomConfiguration(applicationContext: Context): AuthUIConfiguration {
+ val customStringProvider =
+ CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext))
+ return authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = listOf(),
+ serverClientId = ""
+ )
+ )
+ }
+ stringProvider = customStringProvider
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
new file mode 100644
index 000000000..f6c3f03ad
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt
@@ -0,0 +1,472 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.string_provider
+
+import android.content.Context
+import android.content.res.Configuration
+import com.firebase.ui.auth.R
+import java.util.Locale
+
+class DefaultAuthUIStringProvider(
+ context: Context,
+ locale: Locale? = null,
+) : AuthUIStringProvider {
+ /**
+ * Allows overriding locale.
+ */
+ private val localizedContext = locale?.let { locale ->
+ context.createConfigurationContext(
+ Configuration(context.resources.configuration).apply {
+ setLocale(locale)
+ }
+ )
+ } ?: context
+
+ /**
+ * Common Strings
+ */
+ override val initializing: String
+ get() = "Initializing"
+
+ /**
+ * Auth Provider strings
+ */
+ override val googleProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_google)
+ override val facebookProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_facebook)
+ override val twitterProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_twitter)
+ override val githubProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_github)
+ override val phoneProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_phone)
+ override val emailProvider: String
+ get() = localizedContext.getString(R.string.fui_idp_name_email)
+
+ /**
+ * Auth Provider Button Strings
+ */
+ override val signInWithGoogle: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_google)
+ override val signInWithFacebook: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_facebook)
+ override val signInWithTwitter: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_twitter)
+ override val signInWithGithub: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_github)
+ override val signInWithEmail: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_email)
+ override val signInWithPhone: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_phone)
+ override val signInAnonymously: String
+ get() = localizedContext.getString(R.string.fui_sign_in_anonymously)
+ override val signInWithApple: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_apple)
+ override val signInWithMicrosoft: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_microsoft)
+ override val signInWithYahoo: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_yahoo)
+
+ /**
+ * Email Validator Strings
+ */
+ override val missingEmailAddress: String
+ get() = localizedContext.getString(R.string.fui_missing_email_address)
+ override val invalidEmailAddress: String
+ get() = localizedContext.getString(R.string.fui_invalid_email_address)
+
+ /**
+ * Password Validator Strings
+ */
+ override val invalidPassword: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_password)
+ override val passwordsDoNotMatch: String
+ get() = localizedContext.getString(R.string.fui_passwords_do_not_match)
+
+ override fun passwordTooShort(minimumLength: Int): String =
+ localizedContext.getString(R.string.fui_error_password_too_short, minimumLength)
+
+ override val passwordMissingUppercase: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_uppercase)
+ override val passwordMissingLowercase: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_lowercase)
+ override val passwordMissingDigit: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_digit)
+ override val passwordMissingSpecialCharacter: String
+ get() = localizedContext.getString(R.string.fui_error_password_missing_special_character)
+
+ /**
+ * Email Authentication Strings
+ */
+ override val signupPageTitle: String
+ get() = localizedContext.getString(R.string.fui_title_register_email)
+ override val emailHint: String
+ get() = localizedContext.getString(R.string.fui_email_hint)
+ override val passwordHint: String
+ get() = localizedContext.getString(R.string.fui_password_hint)
+ override val confirmPasswordHint: String
+ get() = localizedContext.getString(R.string.fui_confirm_password_hint)
+ override val newPasswordHint: String
+ get() = localizedContext.getString(R.string.fui_new_password_hint)
+ override val nameHint: String
+ get() = localizedContext.getString(R.string.fui_name_hint)
+ override val buttonTextSave: String
+ get() = localizedContext.getString(R.string.fui_button_text_save)
+ override val welcomeBackEmailHeader: String
+ get() = localizedContext.getString(R.string.fui_welcome_back_email_header)
+ override val troubleSigningIn: String
+ get() = localizedContext.getString(R.string.fui_trouble_signing_in)
+
+ override val recoverPasswordPageTitle: String
+ get() = localizedContext.getString(R.string.fui_title_recover_password_activity)
+
+ override val sendButtonText: String
+ get() = localizedContext.getString(R.string.fui_button_text_send)
+
+ override val recoverPasswordLinkSentDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_title_confirm_recover_password)
+
+ override fun recoverPasswordLinkSentDialogBody(email: String): String =
+ localizedContext.getString(R.string.fui_confirm_recovery_body, email)
+
+ override val emailSignInLinkSentDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_header)
+
+ override fun emailSignInLinkSentDialogBody(email: String): String =
+ localizedContext.getString(R.string.fui_email_link_email_sent, email)
+
+ override val orContinueWith: String
+ get() = localizedContext.getString(R.string.fui_or_continue_with)
+
+ override val signInWithEmailLink: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_email_link)
+
+ override val signInWithPassword: String
+ get() = localizedContext.getString(R.string.fui_sign_in_with_password)
+
+ override val emailLinkPromptForEmailTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_confirm_email_header)
+
+ override val emailLinkPromptForEmailMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_confirm_email_message)
+
+ override val emailLinkWrongDeviceTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_wrong_device_header)
+
+ override val emailLinkWrongDeviceMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_wrong_device_message)
+
+ override val emailLinkDifferentAnonymousUserTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_header)
+
+ override val emailLinkDifferentAnonymousUserMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_message)
+
+ override fun emailLinkCrossDeviceLinkingMessage(providerName: String): String =
+ localizedContext.getString(
+ R.string.fui_email_link_cross_device_linking_text,
+ providerName
+ )
+
+ override val emailLinkInvalidLinkTitle: String
+ get() = localizedContext.getString(R.string.fui_email_link_invalid_link_header)
+
+ override val emailLinkInvalidLinkMessage: String
+ get() = localizedContext.getString(R.string.fui_email_link_invalid_link_message)
+
+ override val emailMismatchMessage: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+
+ /**
+ * Phone Authentication Strings
+ */
+ override val verifyPhoneNumberTitle: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number_title)
+ override val phoneHint: String
+ get() = localizedContext.getString(R.string.fui_phone_hint)
+ override val countryHint: String
+ get() = localizedContext.getString(R.string.fui_country_hint)
+ override val invalidPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_invalid_phone_number)
+ override val missingPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+ override val enterConfirmationCode: String
+ get() = localizedContext.getString(R.string.fui_enter_confirmation_code)
+ override val verifyPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number)
+ override val resendCodeIn: String
+ get() = localizedContext.getString(R.string.fui_resend_code_in)
+ override val resendCode: String
+ get() = localizedContext.getString(R.string.fui_resend_code)
+
+ override fun resendCodeTimer(timeFormatted: String): String =
+ localizedContext.getString(R.string.fui_resend_code_in, timeFormatted)
+
+ override val verifying: String
+ get() = localizedContext.getString(R.string.fui_verifying)
+ override val incorrectCodeDialogBody: String
+ get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body)
+ override val smsTermsOfService: String
+ get() = localizedContext.getString(R.string.fui_sms_terms_of_service)
+
+ override val enterPhoneNumberTitle: String
+ get() = localizedContext.getString(R.string.fui_verify_phone_number_title)
+
+ override val phoneNumberHint: String
+ get() = localizedContext.getString(R.string.fui_phone_hint)
+
+ override val sendVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_next_default)
+
+ override fun enterVerificationCodeTitle(phoneNumber: String): String =
+ localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber
+
+ override val verificationCodeHint: String
+ get() = localizedContext.getString(R.string.fui_enter_confirmation_code)
+
+ override val changePhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_change_phone_number)
+
+ override val missingVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+
+ override val invalidVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body)
+
+ override val countrySelectorModalTitle: String
+ get() = localizedContext.getString(R.string.fui_country_selector_title)
+
+ override val searchCountriesHint: String
+ get() = localizedContext.getString(R.string.fui_search_country_field_hint)
+
+ /**
+ * Multi-Factor Authentication Strings
+ */
+ override val enterTOTPCode: String
+ get() = "Enter TOTP Code"
+
+ /**
+ * Provider Picker Strings
+ */
+ override val signInDefault: String
+ get() = localizedContext.getString(R.string.fui_sign_in_default)
+ override val continueText: String
+ get() = localizedContext.getString(R.string.fui_continue)
+ override val nextDefault: String
+ get() = localizedContext.getString(R.string.fui_next_default)
+
+ /**
+ * General Error Messages
+ */
+ override val errorUnknown: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+ override val requiredField: String
+ get() = localizedContext.getString(R.string.fui_required_field)
+ override val progressDialogLoading: String
+ get() = localizedContext.getString(R.string.fui_progress_dialog_loading)
+
+ override fun signedInAs(userIdentifier: String): String =
+ localizedContext.getString(R.string.fui_signed_in_as, userIdentifier)
+
+ override val manageMfaAction: String
+ get() = localizedContext.getString(R.string.fui_manage_mfa_action)
+
+ override val signOutAction: String
+ get() = localizedContext.getString(R.string.fui_sign_out_action)
+
+ override fun verifyEmailInstruction(email: String): String =
+ localizedContext.getString(R.string.fui_verify_email_instruction, email)
+
+ override val resendVerificationEmailAction: String
+ get() = localizedContext.getString(R.string.fui_resend_verification_email_action)
+
+ override val verifiedEmailAction: String
+ get() = localizedContext.getString(R.string.fui_verified_email_action)
+
+ override val profileCompletionMessage: String
+ get() = localizedContext.getString(R.string.fui_profile_completion_message)
+
+ override fun profileMissingFieldsMessage(fields: String): String =
+ localizedContext.getString(R.string.fui_profile_missing_fields_message, fields)
+
+ override val skipAction: String
+ get() = localizedContext.getString(R.string.fui_skip_action)
+
+ override val removeAction: String
+ get() = localizedContext.getString(R.string.fui_remove_action)
+
+ override val backAction: String
+ get() = localizedContext.getString(R.string.fui_back_action)
+
+ override val verifyAction: String
+ get() = localizedContext.getString(R.string.fui_verify_action)
+
+ override val useDifferentMethodAction: String
+ get() = localizedContext.getString(R.string.fui_use_different_method_action)
+
+ override val recoveryCodesSavedAction: String
+ get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action)
+
+ override val secretKeyLabel: String
+ get() = localizedContext.getString(R.string.fui_secret_key_label)
+
+ override val verificationCodeLabel: String
+ get() = localizedContext.getString(R.string.fui_verification_code_label)
+
+ override val identityVerifiedMessage: String
+ get() = localizedContext.getString(R.string.fui_identity_verified_message)
+
+ override val mfaManageFactorsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_manage_factors_title)
+
+ override val mfaManageFactorsDescription: String
+ get() = localizedContext.getString(R.string.fui_mfa_manage_factors_description)
+
+ override val mfaActiveMethodsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_active_methods_title)
+
+ override val mfaAddNewMethodTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_add_new_method_title)
+
+ override val mfaAllMethodsEnrolledMessage: String
+ get() = localizedContext.getString(R.string.fui_mfa_all_methods_enrolled_message)
+
+ override val smsAuthenticationLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_sms_authentication)
+
+ override val totpAuthenticationLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_totp_authentication)
+
+ override val unknownMethodLabel: String
+ get() = localizedContext.getString(R.string.fui_mfa_label_unknown_method)
+
+ override fun enrolledOnDateLabel(date: String): String =
+ localizedContext.getString(R.string.fui_mfa_enrolled_on, date)
+
+ override val setupAuthenticatorDescription: String
+ get() = localizedContext.getString(R.string.fui_mfa_setup_authenticator_description)
+ override val noInternet: String
+ get() = localizedContext.getString(R.string.fui_no_internet)
+
+ /**
+ * Error Recovery Dialog Strings
+ */
+ override val errorDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_error_dialog_title)
+ override val retryAction: String
+ get() = localizedContext.getString(R.string.fui_error_retry_action)
+ override val dismissAction: String
+ get() = localizedContext.getString(R.string.fui_email_link_dismiss_button)
+ override val networkErrorRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_no_internet)
+ override val invalidCredentialsRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_invalid_password)
+ override val userNotFoundRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_email_does_not_exist)
+ override val weakPasswordRecoveryMessage: String
+ get() = localizedContext.resources.getQuantityString(
+ R.plurals.fui_error_weak_password,
+ 6,
+ 6
+ )
+ override val emailAlreadyInUseRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_email_account_creation_error)
+ override val tooManyRequestsRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_too_many_attempts)
+ override val mfaRequiredRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_mfa_required_message)
+ override val accountLinkingRequiredRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_account_linking_required_message)
+ override val authCancelledRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_auth_cancelled_message)
+ override val unknownErrorRecoveryMessage: String
+ get() = localizedContext.getString(R.string.fui_error_unknown)
+
+ /**
+ * MFA Enrollment Step Titles
+ */
+ override val mfaStepSelectFactorTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_title)
+ override val mfaStepConfigureSmsTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_title)
+ override val mfaStepConfigureTotpTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title)
+ override val mfaStepVerifyFactorTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_title)
+ override val mfaStepShowRecoveryCodesTitle: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_title)
+
+ /**
+ * MFA Enrollment Helper Text
+ */
+ override val mfaStepSelectFactorHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_helper)
+ override val mfaStepConfigureSmsHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_helper)
+ override val mfaStepConfigureTotpHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_helper)
+ override val mfaStepVerifyFactorSmsHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_sms_helper)
+ override val mfaStepVerifyFactorTotpHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper)
+ override val mfaStepVerifyFactorGenericHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_generic_helper)
+ override val mfaStepShowRecoveryCodesHelper: String
+ get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper)
+
+ // MFA Enrollment Screen Titles
+ override val mfaEnrollmentEnterPhoneNumber: String
+ get() = localizedContext.getString(R.string.fui_mfa_enrollment_enter_phone_number)
+ override val mfaEnrollmentVerifySmsCode: String
+ get() = localizedContext.getString(R.string.fui_mfa_enrollment_verify_sms_code)
+
+ // MFA Error Messages
+ override val mfaErrorRecentLoginRequired: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_recent_login_required)
+ override val mfaErrorInvalidVerificationCode: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_invalid_verification_code)
+ override val mfaErrorNetwork: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_network)
+ override val mfaErrorGeneric: String
+ get() = localizedContext.getString(R.string.fui_mfa_error_generic)
+
+ override val reauthDialogTitle: String
+ get() = localizedContext.getString(R.string.fui_reauth_dialog_title)
+
+ override val reauthDialogMessage: String
+ get() = localizedContext.getString(R.string.fui_reauth_dialog_message)
+
+ override fun reauthAccountLabel(email: String): String =
+ localizedContext.getString(R.string.fui_reauth_account_label, email)
+
+ override val incorrectPasswordError: String
+ get() = localizedContext.getString(R.string.fui_incorrect_password_error)
+
+ override val reauthGenericError: String
+ get() = localizedContext.getString(R.string.fui_reauth_generic_error)
+
+ override val termsOfService: String
+ get() = localizedContext.getString(R.string.fui_terms_of_service)
+
+ override val privacyPolicy: String
+ get() = localizedContext.getString(R.string.fui_privacy_policy)
+
+ override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String =
+ localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel)
+
+ override val newAccountsDisabledTooltip: String
+ get() = localizedContext.getString(R.string.fui_new_accounts_disabled_tooltip)
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt
new file mode 100644
index 000000000..c59fcf66e
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUIAsset.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.theme
+
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+
+/**
+ * Represents a visual asset used in the authentication UI.
+ *
+ * This sealed class allows specifying icons and images from either Android drawable
+ * resources ([Resource]) or Jetpack Compose [ImageVector]s ([Vector]). The [painter]
+ * property provides a unified way to get a [Painter] for the asset within a composable.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * // To use a drawable resource:
+ * val asset = AuthUIAsset.Resource(R.drawable.my_logo)
+ *
+ * // To use a vector asset:
+ * val vectorAsset = AuthUIAsset.Vector(Icons.Default.Info)
+ * ```
+ */
+sealed class AuthUIAsset {
+ /**
+ * An asset loaded from a drawable resource.
+ *
+ * @param resId The resource ID of the drawable (e.g., `R.drawable.my_icon`).
+ */
+ class Resource(@param:DrawableRes val resId: Int) : AuthUIAsset()
+
+ /**
+ * An asset represented by an [ImageVector].
+ *
+ * @param image The [ImageVector] to be displayed.
+ */
+ class Vector(val image: ImageVector) : AuthUIAsset()
+
+ /**
+ * A [Painter] that can be used to draw this asset in a composable.
+ *
+ * This property automatically resolves the asset type and returns the appropriate
+ * [Painter] for rendering.
+ */
+ @get:Composable
+ internal val painter: Painter
+ get() = when (this) {
+ is Resource -> painterResource(resId)
+ is Vector -> rememberVectorPainter(image)
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
new file mode 100644
index 000000000..a2e8e143a
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/AuthUITheme.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Theming configuration for the entire Auth UI.
+ */
+class AuthUITheme(
+ /**
+ * The color scheme to use.
+ */
+ val colorScheme: ColorScheme,
+
+ /**
+ * The typography to use.
+ */
+ val typography: Typography,
+
+ /**
+ * The shapes to use for UI elements.
+ */
+ val shapes: Shapes,
+
+ /**
+ * A map of provider IDs to custom styling.
+ */
+ val providerStyles: Map = emptyMap()
+) {
+
+ /**
+ * A class nested within AuthUITheme that defines the visual appearance of a specific
+ * provider button, allowing for per-provider branding and customization.
+ */
+ class ProviderStyle(
+ /**
+ * The provider's icon.
+ */
+ val icon: AuthUIAsset?,
+
+ /**
+ * The background color of the button.
+ */
+ val backgroundColor: Color,
+
+ /**
+ * The color of the text label on the button.
+ */
+ val contentColor: Color,
+
+ /**
+ * An optional tint color for the provider's icon. If null,
+ * the icon's intrinsic color is used.
+ */
+ var iconTint: Color? = null,
+
+ /**
+ * The shape of the button container. Defaults to RoundedCornerShape(4.dp).
+ */
+ val shape: Shape = RoundedCornerShape(4.dp),
+
+ /**
+ * The shadow elevation for the button. Defaults to 2.dp.
+ */
+ val elevation: Dp = 2.dp
+ ) {
+ internal companion object {
+ /**
+ * A fallback style for unknown providers with no icon, white background,
+ * and black text.
+ */
+ val Empty = ProviderStyle(
+ icon = null,
+ backgroundColor = Color.White,
+ contentColor = Color.Black,
+ )
+ }
+ }
+
+ companion object {
+ /**
+ * A standard light theme with Material 3 defaults and
+ * pre-configured provider styles.
+ */
+ val Default = AuthUITheme(
+ colorScheme = lightColorScheme(),
+ typography = Typography(),
+ shapes = Shapes(),
+ providerStyles = ProviderStyleDefaults.default
+ )
+
+ val DefaultDark = AuthUITheme(
+ colorScheme = darkColorScheme(),
+ typography = Typography(),
+ shapes = Shapes(),
+ providerStyles = ProviderStyleDefaults.default
+ )
+
+ /**
+ * Creates a theme inheriting the app's current Material
+ * Theme settings.
+ */
+ @Composable
+ fun fromMaterialTheme(
+ providerStyles: Map = ProviderStyleDefaults.default
+ ): AuthUITheme {
+ return AuthUITheme(
+ colorScheme = MaterialTheme.colorScheme,
+ typography = MaterialTheme.typography,
+ shapes = MaterialTheme.shapes,
+ providerStyles = providerStyles
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @get:Composable
+ val topAppBarColors
+ get() = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = MaterialTheme.colorScheme.onPrimary,
+ navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+}
+
+@Composable
+fun AuthUITheme(
+ theme: AuthUITheme = if (isSystemInDarkTheme())
+ AuthUITheme.DefaultDark else AuthUITheme.Default,
+ content: @Composable () -> Unit
+) {
+ MaterialTheme(
+ colorScheme = theme.colorScheme,
+ typography = theme.typography,
+ shapes = theme.shapes,
+ content = content
+ )
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt
new file mode 100644
index 000000000..051758528
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/theme/ProviderStyleDefaults.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.theme
+
+import androidx.compose.ui.graphics.Color
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+
+/**
+ * Default provider styling configurations for authentication providers.
+ *
+ * This object provides brand-appropriate visual styling for each supported authentication
+ * provider, including background colors, text colors, and other visual properties that
+ * match each provider's brand guidelines.
+ *
+ * The styles are automatically applied when using [AuthUITheme.Default] or can be
+ * customized by passing a modified map to [AuthUITheme.fromMaterialTheme].
+ */
+internal object ProviderStyleDefaults {
+ val default: Map
+ get() = Provider.entries.associate { provider ->
+ when (provider) {
+ Provider.GOOGLE -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_googleg_color_24dp),
+ backgroundColor = Color.White,
+ contentColor = Color(0xFF757575)
+ )
+ }
+
+ Provider.FACEBOOK -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp),
+ backgroundColor = Color(0xFF1877F2),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.TWITTER -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_x_white_24dp),
+ backgroundColor = Color.Black,
+ contentColor = Color.White
+ )
+ }
+
+ Provider.GITHUB -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp),
+ backgroundColor = Color(0xFF24292E),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.EMAIL -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp),
+ backgroundColor = Color(0xFFD0021B),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.PHONE -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp),
+ backgroundColor = Color(0xFF43C5A5),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.ANONYMOUS -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp),
+ backgroundColor = Color(0xFFF4B400),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.MICROSOFT -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp),
+ backgroundColor = Color(0xFF2F2F2F),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.YAHOO -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp),
+ backgroundColor = Color(0xFF720E9E),
+ contentColor = Color.White
+ )
+ }
+
+ Provider.APPLE -> {
+ provider.id to AuthUITheme.ProviderStyle(
+ icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp),
+ backgroundColor = Color.Black,
+ contentColor = Color.White
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt
new file mode 100644
index 000000000..0bcee25bc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/EmailValidator.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingEmailAddress
+ )
+ return false
+ }
+
+ if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidEmailAddress
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/util/ui/fieldvalidators/package-info.java b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
similarity index 63%
rename from auth/src/main/java/com/firebase/ui/auth/util/ui/fieldvalidators/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
index 9ed929a57..a72313560 100644
--- a/auth/src/main/java/com/firebase/ui/auth/util/ui/fieldvalidators/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidationStatus.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
@@ -12,9 +12,13 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.configuration.validators
+
/**
- * Contains utility classes for validating {@link android.widget.EditText} field contents. The
- * contents of this package should be considered an implementation detail and not part of the main
- * API.
+ * Class for encapsulating [hasError] and [errorMessage] properties in
+ * internal FieldValidator subclasses.
*/
-package com.firebase.ui.auth.util.ui.fieldvalidators;
+internal class FieldValidationStatus(
+ val hasError: Boolean,
+ val errorMessage: String? = null,
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt
new file mode 100644
index 000000000..4a6924f50
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/FieldValidator.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * An interface for validating input fields.
+ */
+interface FieldValidator {
+ val stringProvider: AuthUIStringProvider
+
+ /**
+ * Returns true if the last validation failed.
+ */
+ val hasError: Boolean
+
+ /**
+ * The error message for the current state.
+ */
+ val errorMessage: String
+
+ /**
+ * Runs validation on a value and returns true if valid.
+ */
+ fun validate(value: String): Boolean
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt
new file mode 100644
index 000000000..a72c9f80d
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/GeneralFieldValidator.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class GeneralFieldValidator(
+ override val stringProvider: AuthUIStringProvider,
+ val isValid: ((String) -> Boolean)? = null,
+ val customMessage: String? = null,
+) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredField
+ )
+ return false
+ }
+
+ if (isValid != null && !isValid(value)) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = customMessage
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
new file mode 100644
index 000000000..b7a8a70eb
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PasswordValidator.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class PasswordValidator(
+ override val stringProvider: AuthUIStringProvider,
+ private val rules: List
+) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPassword
+ )
+ return false
+ }
+
+ for (rule in rules) {
+ if (!rule.isValid(value)) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = rule.getErrorMessage(stringProvider)
+ )
+ return false
+ }
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt
new file mode 100644
index 000000000..1d2484bbc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/PhoneNumberValidator.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.data.CountryData
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+
+internal class PhoneNumberValidator(
+ override val stringProvider: AuthUIStringProvider,
+ val selectedCountry: CountryData,
+) :
+ FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ private val phoneNumberUtil = PhoneNumberUtil.getInstance()
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingPhoneNumber
+ )
+ return false
+ }
+
+ try {
+ val phoneNumber = phoneNumberUtil.parse(value, selectedCountry.countryCode)
+ val isValid = phoneNumberUtil.isValidNumber(phoneNumber)
+
+ if (!isValid) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPhoneNumber
+ )
+ return false
+ }
+ } catch (_: NumberParseException) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidPhoneNumber
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt
new file mode 100644
index 000000000..9824a8161
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/validators/VerificationCodeValidator.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.configuration.validators
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+internal class VerificationCodeValidator(override val stringProvider: AuthUIStringProvider) :
+ FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.missingVerificationCode
+ )
+ return false
+ }
+
+ // Verification codes are typically 6 digits
+ val digitsOnly = value.replace(Regex("[^0-9]"), "")
+ if (digitsOnly.length != 6) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.invalidVerificationCode
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
similarity index 57%
rename from auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java
rename to auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
index 31d0137e0..535ace99d 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredential.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2016 Google Inc. All Rights Reserved.
+ * Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
@@ -12,7 +12,15 @@
* limitations under the License.
*/
+package com.firebase.ui.auth.credentialmanager
+
/**
- * Activites related to identity provider authentication.
+ * Represents a password credential retrieved from the system credential manager.
+ *
+ * @property username The username/identifier associated with the credential
+ * @property password The password associated with the credential
*/
-package com.firebase.ui.auth.ui.idp;
+data class PasswordCredential(
+ val username: String,
+ val password: String
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt
new file mode 100644
index 000000000..65e0ac0c0
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/credentialmanager/PasswordCredentialHandler.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.credentialmanager
+
+import android.content.Context
+import androidx.credentials.CreatePasswordRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetPasswordOption
+import androidx.credentials.PasswordCredential as AndroidPasswordCredential
+import androidx.credentials.exceptions.CreateCredentialCancellationException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+
+/**
+ * Handler for password credential operations using Android's Credential Manager.
+ *
+ * This class provides methods to save and retrieve password credentials through
+ * the system credential manager, which displays native UI prompts to the user.
+ *
+ * @property context The Android context used for credential operations
+ */
+class PasswordCredentialHandler(
+ private val context: Context
+) {
+ private val credentialManager: CredentialManager = CredentialManager.create(context)
+
+ /**
+ * Saves a password credential to the system credential manager.
+ *
+ * This method displays a system prompt to the user asking if they want to save
+ * the credential. The operation is performed asynchronously using Kotlin coroutines.
+ *
+ * @param username The username/identifier for the credential
+ * @param password The password to save
+ * @throws CreateCredentialException if the credential cannot be saved
+ * @throws CreateCredentialCancellationException if the user cancels the save operation
+ * @throws IllegalArgumentException if username or password is blank
+ */
+ suspend fun savePassword(username: String, password: String) {
+ require(username.isNotBlank()) { "Username cannot be blank" }
+ require(password.isNotBlank()) { "Password cannot be blank" }
+
+ val request = CreatePasswordRequest(
+ id = username,
+ password = password
+ )
+
+ try {
+ credentialManager.createCredential(context, request)
+ } catch (e: CreateCredentialCancellationException) {
+ // User cancelled the save operation
+ throw PasswordCredentialCancelledException("User cancelled password save operation", e)
+ } catch (e: CreateCredentialException) {
+ // Other credential creation errors
+ throw PasswordCredentialException("Failed to save password credential", e)
+ }
+ }
+
+ /**
+ * Retrieves a password credential from the system credential manager.
+ *
+ * This method displays a system prompt showing available credentials for the user
+ * to select from. The operation is performed asynchronously using Kotlin coroutines.
+ *
+ * @return PasswordCredential containing the username and password
+ * @throws NoCredentialException if no credentials are available
+ * @throws GetCredentialCancellationException if the user cancels the retrieval operation
+ * @throws GetCredentialException if the credential cannot be retrieved
+ */
+ suspend fun getPassword(): PasswordCredential {
+ val getPasswordOption = GetPasswordOption()
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(getPasswordOption)
+ .build()
+
+ try {
+ val result = credentialManager.getCredential(context, request)
+ val credential = result.credential
+
+ if (credential is AndroidPasswordCredential) {
+ return PasswordCredential(
+ username = credential.id,
+ password = credential.password
+ )
+ } else {
+ throw PasswordCredentialException("Retrieved credential is not a password credential")
+ }
+ } catch (e: GetCredentialCancellationException) {
+ // User cancelled the retrieval operation
+ throw PasswordCredentialCancelledException("User cancelled password retrieval operation", e)
+ } catch (e: NoCredentialException) {
+ // No credentials available
+ throw PasswordCredentialNotFoundException("No password credentials found", e)
+ } catch (e: GetCredentialException) {
+ // Other credential retrieval errors
+ throw PasswordCredentialException("Failed to retrieve password credential", e)
+ }
+ }
+}
+
+/**
+ * Base exception for password credential operations.
+ */
+open class PasswordCredentialException(
+ message: String,
+ cause: Throwable? = null
+) : Exception(message, cause)
+
+/**
+ * Exception thrown when a password credential operation is cancelled by the user.
+ */
+class PasswordCredentialCancelledException(
+ message: String,
+ cause: Throwable? = null
+) : PasswordCredentialException(message, cause)
+
+/**
+ * Exception thrown when no password credentials are found.
+ */
+class PasswordCredentialNotFoundException(
+ message: String,
+ cause: Throwable? = null
+) : PasswordCredentialException(message, cause)
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt b/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt
new file mode 100644
index 000000000..e6400cc60
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/data/Countries.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.data
+
+/**
+ * Complete list of countries with their dial codes and ISO country codes.
+ * Auto-generated from ISO 3166-1 standard.
+ */
+val ALL_COUNTRIES: List = listOf(
+ CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")),
+ CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")),
+ CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")),
+ CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")),
+ CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")),
+ CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")),
+ CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")),
+ CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")),
+ CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")),
+ CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")),
+ CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")),
+ CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")),
+ CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")),
+ CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")),
+ CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")),
+ CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")),
+ CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")),
+ CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")),
+ CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")),
+ CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")),
+ CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")),
+ CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")),
+ CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")),
+ CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")),
+ CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")),
+ CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")),
+ CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")),
+ CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")),
+ CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")),
+ CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")),
+ CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")),
+ CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")),
+ CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")),
+ CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")),
+ CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")),
+ CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")),
+ CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")),
+ CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")),
+ CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")),
+ CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")),
+ CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")),
+ CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")),
+ CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")),
+ CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")),
+ CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")),
+ CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")),
+ CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")),
+ CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")),
+ CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")),
+ CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")),
+ CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")),
+ CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")),
+ CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")),
+ CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")),
+ CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")),
+ CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")),
+ CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")),
+ CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")),
+ CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")),
+ CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")),
+ CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")),
+ CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")),
+ CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")),
+ CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")),
+ CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")),
+ CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")),
+ CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")),
+ CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")),
+ CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")),
+ CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")),
+ CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")),
+ CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")),
+ CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")),
+ CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")),
+ CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")),
+ CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")),
+ CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")),
+ CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")),
+ CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")),
+ CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")),
+ CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")),
+ CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")),
+ CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")),
+ CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")),
+ CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")),
+ CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")),
+ CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")),
+ CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")),
+ CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")),
+ CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")),
+ CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")),
+ CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")),
+ CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")),
+ CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")),
+ CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")),
+ CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")),
+ CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")),
+ CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")),
+ CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")),
+ CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")),
+ CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")),
+ CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")),
+ CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")),
+ CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")),
+ CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")),
+ CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")),
+ CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")),
+ CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")),
+ CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")),
+ CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")),
+ CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")),
+ CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")),
+ CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")),
+ CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")),
+ CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")),
+ CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")),
+ CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")),
+ CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")),
+ CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")),
+ CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")),
+ CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")),
+ CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")),
+ CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")),
+ CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")),
+ CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")),
+ CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")),
+ CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")),
+ CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")),
+ CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")),
+ CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")),
+ CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")),
+ CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")),
+ CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")),
+ CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")),
+ CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")),
+ CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")),
+ CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")),
+ CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")),
+ CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")),
+ CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")),
+ CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")),
+ CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")),
+ CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")),
+ CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")),
+ CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")),
+ CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")),
+ CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")),
+ CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")),
+ CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")),
+ CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")),
+ CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")),
+ CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")),
+ CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")),
+ CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")),
+ CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")),
+ CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")),
+ CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")),
+ CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")),
+ CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")),
+ CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")),
+ CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")),
+ CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")),
+ CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")),
+ CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")),
+ CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")),
+ CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")),
+ CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")),
+ CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")),
+ CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")),
+ CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")),
+ CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")),
+ CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")),
+ CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")),
+ CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")),
+ CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")),
+ CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")),
+ CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")),
+ CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")),
+ CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")),
+ CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")),
+ CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")),
+ CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")),
+ CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")),
+ CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")),
+ CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")),
+ CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")),
+ CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")),
+ CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")),
+ CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")),
+ CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")),
+ CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")),
+ CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")),
+ CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")),
+ CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")),
+ CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")),
+ CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")),
+ CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")),
+ CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")),
+ CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")),
+ CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")),
+ CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")),
+ CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")),
+ CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")),
+ CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")),
+ CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")),
+ CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")),
+ CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")),
+ CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")),
+ CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")),
+ CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")),
+ CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")),
+ CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")),
+ CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")),
+ CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")),
+ CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")),
+ CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")),
+ CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")),
+ CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")),
+ CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")),
+ CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")),
+ CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")),
+ CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")),
+ CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")),
+ CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")),
+ CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")),
+ CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")),
+ CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")),
+ CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")),
+ CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")),
+ CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")),
+ CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")),
+ CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")),
+ CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")),
+ CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")),
+ CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")),
+ CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")),
+ CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")),
+ CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW"))
+)
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt
new file mode 100644
index 000000000..e171f47a8
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.data
+
+/**
+ * Represents country information for phone number authentication.
+ *
+ * @property name The display name of the country (e.g., "United States").
+ * @property dialCode The international dialing code (e.g., "+1").
+ * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US").
+ * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸").
+ */
+data class CountryData(
+ val name: String,
+ val dialCode: String,
+ val countryCode: String,
+ val flagEmoji: String
+) {
+ /**
+ * Returns a formatted display string combining flag emoji and country name.
+ */
+ fun getDisplayName(): String = "$flagEmoji $name"
+
+ /**
+ * Returns a formatted string with dial code.
+ */
+ fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)"
+}
+
+/**
+ * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji.
+ *
+ * @param countryCode The two-letter country code (e.g., "US", "GB", "FR").
+ * @return The flag emoji string, or an empty string if the code is invalid.
+ */
+fun countryCodeToFlagEmoji(countryCode: String): String {
+ if (countryCode.length != 2) return ""
+
+ val uppercaseCode = countryCode.uppercase()
+ val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A
+ val charCodeOffset = 'A'.code
+
+ val firstChar = uppercaseCode[0].code
+ val secondChar = uppercaseCode[1].code
+
+ val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset)
+ val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset)
+
+ return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2)
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/client/AuthUiInitProvider.java b/auth/src/main/java/com/firebase/ui/auth/data/client/AuthUiInitProvider.java
deleted file mode 100644
index d90e875d6..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/client/AuthUiInitProvider.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.firebase.ui.auth.data.client;
-
-import android.content.ContentProvider;
-import android.content.ContentValues;
-import android.content.Context;
-import android.content.pm.ProviderInfo;
-import android.database.Cursor;
-import android.net.Uri;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.util.Preconditions;
-
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class AuthUiInitProvider extends ContentProvider {
- @Override
- public void attachInfo(Context context, ProviderInfo info) {
- Preconditions.checkNotNull(info, "AuthUiInitProvider ProviderInfo cannot be null.");
- if ("com.firebase.ui.auth.authuiinitprovider".equals(info.authority)) {
- throw new IllegalStateException("Incorrect provider authority in manifest. Most" +
- " likely due to a missing applicationId variable in application's build.gradle.");
- } else {
- super.attachInfo(context, info);
- }
- }
-
- @Override
- public boolean onCreate() {
- AuthUI.setApplicationContext(getContext());
- return false;
- }
-
- @Override
- public Cursor query(Uri uri,
- String[] projection,
- String selection,
- String[] selectionArgs,
- String sortOrder) {
- return null;
- }
-
- @Override
- public String getType(Uri uri) {
- return null;
- }
-
- @Override
- public Uri insert(Uri uri, ContentValues values) {
- return null;
- }
-
- @Override
- public int delete(Uri uri, String selection, String[] selectionArgs) {
- return 0;
- }
-
- @Override
- public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
- return 0;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/CountryInfo.java b/auth/src/main/java/com/firebase/ui/auth/data/model/CountryInfo.java
deleted file mode 100644
index d245146eb..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/CountryInfo.java
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * Copyright (C) 2015 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Modifications copyright (C) 2017 Google Inc
- *
- */
-package com.firebase.ui.auth.data.model;
-
-import android.os.Parcel;
-import android.os.Parcelable;
-
-import java.text.Collator;
-import java.util.Locale;
-
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public final class CountryInfo implements Comparable, Parcelable {
-
- public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
- @Override
- public CountryInfo createFromParcel(Parcel source) {
- return new CountryInfo(source);
- }
-
- @Override
- public CountryInfo[] newArray(int size) {
- return new CountryInfo[size];
- }
- };
-
- private final Collator mCollator;
- private final Locale mLocale;
- private final int mCountryCode;
-
- public CountryInfo(Locale locale, int countryCode) {
- mCollator = Collator.getInstance(Locale.getDefault());
- mCollator.setStrength(Collator.PRIMARY);
- mLocale = locale;
- mCountryCode = countryCode;
- }
-
- protected CountryInfo(Parcel in) {
- mCollator = Collator.getInstance(Locale.getDefault());
- mCollator.setStrength(Collator.PRIMARY);
-
- mLocale = (Locale) in.readSerializable();
- mCountryCode = in.readInt();
- }
-
- public static String localeToEmoji(Locale locale) {
- String countryCode = locale.getCountry();
- // 0x41 is Letter A
- // 0x1F1E6 is Regional Indicator Symbol Letter A
- // Example :
- // firstLetter U => 20 + 0x1F1E6
- // secondLetter S => 18 + 0x1F1E6
- // See: https://en.wikipedia.org/wiki/Regional_Indicator_Symbol
- int firstLetter = Character.codePointAt(countryCode, 0) - 0x41 + 0x1F1E6;
- int secondLetter = Character.codePointAt(countryCode, 1) - 0x41 + 0x1F1E6;
- return new String(Character.toChars(firstLetter)) + new String(Character.toChars
- (secondLetter));
- }
-
- public Locale getLocale() {
- return mLocale;
- }
-
- public int getCountryCode() {
- return mCountryCode;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- final CountryInfo that = (CountryInfo) o;
-
- return mCountryCode == that.mCountryCode
- && (mLocale != null ? mLocale.equals(that.mLocale) : that.mLocale == null);
- }
-
- @Override
- public int hashCode() {
- int result = mLocale != null ? mLocale.hashCode() : 0;
- result = 31 * result + mCountryCode;
- return result;
- }
-
- @Override
- public String toString() {
- return localeToEmoji(mLocale) + " " + mLocale.getDisplayCountry() + " +" + mCountryCode;
- }
-
- public String toShortString() {
- return localeToEmoji(mLocale) + " +" + mCountryCode;
- }
-
- @Override
- public int compareTo(CountryInfo info) {
- Locale defaultLocale = Locale.getDefault();
- return mCollator.compare(
- mLocale.getDisplayCountry().toUpperCase(defaultLocale),
- info.mLocale.getDisplayCountry().toUpperCase(defaultLocale));
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeSerializable(mLocale);
- dest.writeInt(mCountryCode);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/FirebaseAuthUIAuthenticationResult.java b/auth/src/main/java/com/firebase/ui/auth/data/model/FirebaseAuthUIAuthenticationResult.java
deleted file mode 100644
index eea7deabc..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/FirebaseAuthUIAuthenticationResult.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import com.firebase.ui.auth.FirebaseAuthUIActivityResultContract;
-import com.firebase.ui.auth.IdpResponse;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-/**
- * Result of launching a {@link FirebaseAuthUIActivityResultContract}
- */
-public class FirebaseAuthUIAuthenticationResult {
-
- @Nullable
- private final IdpResponse idpResponse;
- @NonNull
- private final Integer resultCode;
-
- public FirebaseAuthUIAuthenticationResult(@NonNull Integer resultCode, @Nullable IdpResponse idpResponse) {
- this.idpResponse = idpResponse;
- this.resultCode = resultCode;
- }
-
- /**
- * The contained {@link IdpResponse} returned from the Firebase library
- */
- @Nullable
- public IdpResponse getIdpResponse() {
- return idpResponse;
- }
-
- /**
- * The result code of the received activity result
- *
- * @see android.app.Activity.RESULT_CANCELED
- * @see android.app.Activity.RESULT_OK
- */
- @NonNull
- public Integer getResultCode() {
- return resultCode;
- }
-
- @Override
- public int hashCode() {
- int result = idpResponse == null ? 0 : idpResponse.hashCode();
- result = 31 * result + resultCode.hashCode();
- return result;
- }
-
- @Override
- public String toString() {
- return "FirebaseAuthUIAuthenticationResult{" +
- "idpResponse=" + idpResponse +
- ", resultCode='" + resultCode +
- '}';
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/FlowParameters.java b/auth/src/main/java/com/firebase/ui/auth/data/model/FlowParameters.java
deleted file mode 100644
index d13187c7d..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/FlowParameters.java
+++ /dev/null
@@ -1,222 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.firebase.ui.auth.data.model;
-
-import android.content.Intent;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.text.TextUtils;
-
-import com.firebase.ui.auth.AuthMethodPickerLayout;
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.AuthUI.IdpConfig;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.Preconditions;
-import com.google.firebase.auth.ActionCodeSettings;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-import java.util.Collections;
-import java.util.List;
-
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StyleRes;
-
-/**
- * Encapsulates the core parameters and data captured during the authentication flow, in a
- * serializable manner, in order to pass data between activities.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class FlowParameters implements Parcelable {
-
- public static final Creator CREATOR = new Creator() {
- @Override
- public FlowParameters createFromParcel(Parcel in) {
- String appName = in.readString();
- List providerInfo = in.createTypedArrayList(IdpConfig.CREATOR);
- IdpConfig defaultProvider = in.readParcelable(IdpConfig.class.getClassLoader());
- int themeId = in.readInt();
- int logoId = in.readInt();
- String termsOfServiceUrl = in.readString();
- String privacyPolicyUrl = in.readString();
- boolean enableCredentials = in.readInt() != 0;
- boolean enableAnonymousUpgrade = in.readInt() != 0;
- boolean alwaysShowProviderChoice = in.readInt() != 0;
- boolean lockOrientation = in.readInt() != 0;
- String emailLink = in.readString();
- ActionCodeSettings passwordResetSettings = in.readParcelable(ActionCodeSettings.class.getClassLoader());
- AuthMethodPickerLayout customLayout = in.readParcelable(AuthMethodPickerLayout.class.getClassLoader());
-
- return new FlowParameters(
- appName,
- providerInfo,
- defaultProvider,
- themeId,
- logoId,
- termsOfServiceUrl,
- privacyPolicyUrl,
- enableCredentials,
- enableAnonymousUpgrade,
- alwaysShowProviderChoice,
- lockOrientation,
- emailLink,
- passwordResetSettings,
- customLayout);
- }
-
- @Override
- public FlowParameters[] newArray(int size) {
- return new FlowParameters[size];
- }
- };
-
- @NonNull
- public final String appName;
-
- @NonNull
- public final List providers;
-
- @Nullable
- public final IdpConfig defaultProvider;
-
- @StyleRes
- public final int themeId;
-
- @DrawableRes
- public final int logoId;
-
- @Nullable
- public final String termsOfServiceUrl;
-
- @Nullable
- public final String privacyPolicyUrl;
-
- @Nullable
- public String emailLink;
-
- @Nullable
- public final ActionCodeSettings passwordResetSettings;
-
- public final boolean enableCredentials;
- public final boolean enableAnonymousUpgrade;
- public final boolean alwaysShowProviderChoice;
- public final boolean lockOrientation;
-
- @Nullable
- public final AuthMethodPickerLayout authMethodPickerLayout;
-
- public FlowParameters(
- @NonNull String appName,
- @NonNull List providers,
- @Nullable IdpConfig defaultProvider,
- @StyleRes int themeId,
- @DrawableRes int logoId,
- @Nullable String termsOfServiceUrl,
- @Nullable String privacyPolicyUrl,
- boolean enableCredentials,
- boolean enableAnonymousUpgrade,
- boolean alwaysShowProviderChoice,
- boolean lockOrientation,
- @Nullable String emailLink,
- @Nullable ActionCodeSettings passwordResetSettings,
- @Nullable AuthMethodPickerLayout authMethodPickerLayout) {
- this.appName = Preconditions.checkNotNull(appName, "appName cannot be null");
- this.providers = Collections.unmodifiableList(
- Preconditions.checkNotNull(providers, "providers cannot be null"));
- this.defaultProvider = defaultProvider;
- this.themeId = themeId;
- this.logoId = logoId;
- this.termsOfServiceUrl = termsOfServiceUrl;
- this.privacyPolicyUrl = privacyPolicyUrl;
- this.enableCredentials = enableCredentials;
- this.enableAnonymousUpgrade = enableAnonymousUpgrade;
- this.alwaysShowProviderChoice = alwaysShowProviderChoice;
- this.lockOrientation = lockOrientation;
- this.emailLink = emailLink;
- this.passwordResetSettings = passwordResetSettings;
- this.authMethodPickerLayout = authMethodPickerLayout;
- }
-
- /**
- * Extract FlowParameters from an Intent.
- */
- public static FlowParameters fromIntent(Intent intent) {
- return intent.getParcelableExtra(ExtraConstants.FLOW_PARAMS);
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeString(appName);
- dest.writeTypedList(providers);
- dest.writeParcelable(defaultProvider, flags);
- dest.writeInt(themeId);
- dest.writeInt(logoId);
- dest.writeString(termsOfServiceUrl);
- dest.writeString(privacyPolicyUrl);
- dest.writeInt(enableCredentials ? 1 : 0);
- dest.writeInt(enableAnonymousUpgrade ? 1 : 0);
- dest.writeInt(alwaysShowProviderChoice ? 1 : 0);
- dest.writeInt(lockOrientation ? 1 : 0);
- dest.writeString(emailLink);
- dest.writeParcelable(passwordResetSettings, flags);
- dest.writeParcelable(authMethodPickerLayout, flags);
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- public boolean isSingleProviderFlow() {
- return providers.size() == 1;
- }
-
- public boolean isTermsOfServiceUrlProvided() {
- return !TextUtils.isEmpty(termsOfServiceUrl);
- }
-
- public boolean isPrivacyPolicyUrlProvided() {
- return !TextUtils.isEmpty(privacyPolicyUrl);
- }
-
- public boolean isAnonymousUpgradeEnabled() {
- return enableAnonymousUpgrade;
- }
-
- public boolean isPlayServicesRequired() {
- // Play services only required for Google Sign In and the Credentials API
- return isProviderEnabled(GoogleAuthProvider.PROVIDER_ID)
- || enableCredentials;
- }
-
- public boolean isProviderEnabled(@AuthUI.SupportedProvider String provider) {
- for (AuthUI.IdpConfig idp : providers) {
- if (idp.getProviderId().equals(provider)) {
- return true;
- }
- }
-
- return false;
- }
-
- public boolean shouldShowProviderChoice() {
- return defaultProvider == null && (!isSingleProviderFlow() || alwaysShowProviderChoice);
- }
-
- public IdpConfig getDefaultOrFirstProvider() {
- return defaultProvider != null ? defaultProvider : providers.get(0);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/IntentRequiredException.java b/auth/src/main/java/com/firebase/ui/auth/data/model/IntentRequiredException.java
deleted file mode 100644
index 8203f21c9..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/IntentRequiredException.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import android.content.Intent;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class IntentRequiredException extends FirebaseUiException {
- private final Intent mIntent;
- private final int mRequestCode;
-
- public IntentRequiredException(@NonNull Intent intent, int requestCode) {
- super(ErrorCodes.UNKNOWN_ERROR);
- mIntent = intent;
- mRequestCode = requestCode;
- }
-
- @NonNull
- public Intent getIntent() {
- return mIntent;
- }
-
- public int getRequestCode() {
- return mRequestCode;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/PendingIntentRequiredException.java b/auth/src/main/java/com/firebase/ui/auth/data/model/PendingIntentRequiredException.java
deleted file mode 100644
index fddd85a95..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/PendingIntentRequiredException.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import android.app.PendingIntent;
-import android.content.IntentSender;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class PendingIntentRequiredException extends FirebaseUiException {
- private final PendingIntent mPendingIntent;
- private final IntentSender mIntentSender;
- private final int mRequestCode;
-
- /**
- * Constructor for cases when a PendingIntent is available.
- *
- * @param pendingIntent The PendingIntent required to complete the operation.
- * @param requestCode The associated request code.
- */
- public PendingIntentRequiredException(@NonNull PendingIntent pendingIntent, int requestCode) {
- super(ErrorCodes.UNKNOWN_ERROR);
- mPendingIntent = pendingIntent;
- mIntentSender = null;
- mRequestCode = requestCode;
- }
-
- /**
- * Constructor for cases when an IntentSender is available.
- *
- * @param intentSender The IntentSender required to complete the operation.
- * @param requestCode The associated request code.
- */
- public PendingIntentRequiredException(@NonNull IntentSender intentSender, int requestCode) {
- super(ErrorCodes.UNKNOWN_ERROR);
- mIntentSender = intentSender;
- mPendingIntent = null;
- mRequestCode = requestCode;
- }
-
- /**
- * Returns the PendingIntent, if available.
- *
- * @return The PendingIntent or null if not available.
- */
- public PendingIntent getPendingIntent() {
- return mPendingIntent;
- }
-
- /**
- * Returns the IntentSender, if available.
- *
- * @return The IntentSender or null if not available.
- */
- public IntentSender getIntentSender() {
- return mIntentSender;
- }
-
- public int getRequestCode() {
- return mRequestCode;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumber.java b/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumber.java
deleted file mode 100644
index be3c6c25a..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumber.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright (C) 2015 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Modifications copyright (C) 2017 Google Inc
- */
-package com.firebase.ui.auth.data.model;
-
-import android.text.TextUtils;
-
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public final class PhoneNumber {
- private static final PhoneNumber EMPTY_PHONE_NUMBER = new PhoneNumber("", "", "");
-
- private final String mPhoneNumber;
- private final String mCountryIso;
- private final String mCountryCode;
-
- public PhoneNumber(String phoneNumber, String countryIso, String countryCode) {
- mPhoneNumber = phoneNumber;
- mCountryIso = countryIso;
- mCountryCode = countryCode;
- }
-
- /**
- * Returns an empty instance of this class
- */
- public static PhoneNumber emptyPhone() {
- return EMPTY_PHONE_NUMBER;
- }
-
- public static boolean isValid(PhoneNumber phoneNumber) {
- return phoneNumber != null
- && !EMPTY_PHONE_NUMBER.equals(phoneNumber)
- && !TextUtils.isEmpty(phoneNumber.getPhoneNumber())
- && !TextUtils.isEmpty(phoneNumber.getCountryCode())
- && !TextUtils.isEmpty(phoneNumber.getCountryIso());
- }
-
- public static boolean isCountryValid(PhoneNumber phoneNumber) {
- return phoneNumber != null
- && !EMPTY_PHONE_NUMBER.equals(phoneNumber)
- && !TextUtils.isEmpty(phoneNumber.getCountryCode())
- && !TextUtils.isEmpty(phoneNumber.getCountryIso());
- }
-
- /**
- * Returns country code
- */
- public String getCountryCode() {
- return mCountryCode;
- }
-
- /**
- * Returns phone number without country code
- */
- public String getPhoneNumber() {
- return mPhoneNumber;
- }
-
- /**
- * Returns 2 char country ISO
- */
- public String getCountryIso() {
- return mCountryIso;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumberVerificationRequiredException.java b/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumberVerificationRequiredException.java
deleted file mode 100644
index 9093b018e..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/PhoneNumberVerificationRequiredException.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-/**
- * Represents an error in which the phone number couldn't be automatically verified and must
- * therefore be manually verified by the client by sending an SMS code.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class PhoneNumberVerificationRequiredException extends FirebaseUiException {
- private final String mPhoneNumber;
-
- /**
- * @param number the phone number requiring verification, formatted with a country code prefix
- */
- public PhoneNumberVerificationRequiredException(@NonNull String number) {
- super(ErrorCodes.PROVIDER_ERROR, "Phone number requires verification.");
- mPhoneNumber = number;
- }
-
- /**
- * @return the phone number requiring verification
- */
- @NonNull
- public String getPhoneNumber() {
- return mPhoneNumber;
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/Resource.java b/auth/src/main/java/com/firebase/ui/auth/data/model/Resource.java
deleted file mode 100644
index 3d95bd139..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/Resource.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-/**
- * Base state model object.
- *
- * This state can either be successful or not. In either case, it must be complete to represent
- * these states.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public final class Resource {
- private final State mState;
- private final T mValue;
- private final Exception mException;
-
- private boolean mIsUsed;
-
- private Resource(State state, T value, Exception exception) {
- mState = state;
- mValue = value;
- mException = exception;
- }
-
- /**
- * Creates a successful resource containing a value.
- */
- @NonNull
- public static Resource forSuccess(@NonNull T value) {
- return new Resource<>(State.SUCCESS, value, null);
- }
-
- /**
- * Creates a failed resource with an exception.
- */
- @NonNull
- public static Resource forFailure(@NonNull Exception e) {
- return new Resource<>(State.FAILURE, null, e);
- }
-
- /**
- * Creates a resource in the loading state, without a value or an exception.
- */
- @NonNull
- public static Resource forLoading() {
- return new Resource<>(State.LOADING, null, null);
- }
-
- @NonNull
- public State getState() {
- return mState;
- }
-
- @Nullable
- public final Exception getException() {
- mIsUsed = true;
- return mException;
- }
-
- @Nullable
- public T getValue() {
- mIsUsed = true;
- return mValue;
- }
-
- public boolean isUsed() {
- return mIsUsed;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- Resource> resource = (Resource>) o;
-
- return mState == resource.mState
- && (mValue == null ? resource.mValue == null : mValue.equals(resource.mValue))
- && (mException == null ? resource.mException == null : mException.equals(resource.mException));
- }
-
- @Override
- public int hashCode() {
- int result = mState.hashCode();
- result = 31 * result + (mValue == null ? 0 : mValue.hashCode());
- result = 31 * result + (mException == null ? 0 : mException.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- return "Resource{" +
- "mState=" + mState +
- ", mValue=" + mValue +
- ", mException=" + mException +
- '}';
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/State.java b/auth/src/main/java/com/firebase/ui/auth/data/model/State.java
deleted file mode 100644
index 0bab403b1..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/State.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public enum State {
- SUCCESS, FAILURE, LOADING
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/User.java b/auth/src/main/java/com/firebase/ui/auth/data/model/User.java
deleted file mode 100644
index 3979b2a8f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/User.java
+++ /dev/null
@@ -1,165 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.util.ExtraConstants;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class User implements Parcelable {
- public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
- @Override
- public User createFromParcel(Parcel in) {
- return new User(
- in.readString(),
- in.readString(),
- in.readString(),
- in.readString(),
- in.readParcelable(Uri.class.getClassLoader()));
- }
-
- @Override
- public User[] newArray(int size) {
- return new User[size];
- }
- };
-
- private final String mProviderId;
- private final String mEmail;
- private final String mPhoneNumber;
- private final String mName;
- private final Uri mPhotoUri;
-
- private User(String providerId, String email, String phoneNumber, String name, Uri photoUri) {
- mProviderId = providerId;
- mEmail = email;
- mPhoneNumber = phoneNumber;
- mName = name;
- mPhotoUri = photoUri;
- }
-
- public static User getUser(Intent intent) {
- return intent.getParcelableExtra(ExtraConstants.USER);
- }
-
- public static User getUser(Bundle arguments) {
- return arguments.getParcelable(ExtraConstants.USER);
- }
-
- @NonNull
- @AuthUI.SupportedProvider
- public String getProviderId() {
- return mProviderId;
- }
-
- @Nullable
- public String getEmail() {
- return mEmail;
- }
-
- @Nullable
- public String getPhoneNumber() {
- return mPhoneNumber;
- }
-
- @Nullable
- public String getName() {
- return mName;
- }
-
- @Nullable
- public Uri getPhotoUri() {
- return mPhotoUri;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
-
- User user = (User) o;
-
- return mProviderId.equals(user.mProviderId)
- && (mEmail == null ? user.mEmail == null : mEmail.equals(user.mEmail))
- && (mPhoneNumber == null ? user.mPhoneNumber == null : mPhoneNumber.equals(user.mPhoneNumber))
- && (mName == null ? user.mName == null : mName.equals(user.mName))
- && (mPhotoUri == null ? user.mPhotoUri == null : mPhotoUri.equals(user.mPhotoUri));
- }
-
- @Override
- public int hashCode() {
- int result = mProviderId.hashCode();
- result = 31 * result + (mEmail == null ? 0 : mEmail.hashCode());
- result = 31 * result + (mPhoneNumber == null ? 0 : mPhoneNumber.hashCode());
- result = 31 * result + (mName == null ? 0 : mName.hashCode());
- result = 31 * result + (mPhotoUri == null ? 0 : mPhotoUri.hashCode());
- return result;
- }
-
- @Override
- public String toString() {
- return "User{" +
- "mProviderId='" + mProviderId + '\'' +
- ", mEmail='" + mEmail + '\'' +
- ", mPhoneNumber='" + mPhoneNumber + '\'' +
- ", mName='" + mName + '\'' +
- ", mPhotoUri=" + mPhotoUri +
- '}';
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(@NonNull Parcel dest, int flags) {
- dest.writeString(mProviderId);
- dest.writeString(mEmail);
- dest.writeString(mPhoneNumber);
- dest.writeString(mName);
- dest.writeParcelable(mPhotoUri, flags);
- }
-
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- public static class Builder {
- private String mProviderId;
- private String mEmail;
- private String mPhoneNumber;
- private String mName;
- private Uri mPhotoUri;
-
- public Builder(@AuthUI.SupportedProvider @NonNull String providerId,
- @Nullable String email) {
- mProviderId = providerId;
- mEmail = email;
- }
-
- public Builder setPhoneNumber(String phoneNumber) {
- mPhoneNumber = phoneNumber;
- return this;
- }
-
- public Builder setName(String name) {
- mName = name;
- return this;
- }
-
- public Builder setPhotoUri(Uri photoUri) {
- mPhotoUri = photoUri;
- return this;
- }
-
- public User build() {
- return new User(mProviderId, mEmail, mPhoneNumber, mName, mPhotoUri);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/model/UserCancellationException.java b/auth/src/main/java/com/firebase/ui/auth/data/model/UserCancellationException.java
deleted file mode 100644
index 8c8280948..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/model/UserCancellationException.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.firebase.ui.auth.data.model;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class UserCancellationException extends FirebaseUiException {
- public UserCancellationException() {
- super(ErrorCodes.UNKNOWN_ERROR);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/AnonymousSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/AnonymousSignInHandler.java
deleted file mode 100644
index daf873700..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/AnonymousSignInHandler.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-import android.content.Intent;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.FirebaseApp;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FirebaseAuth;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.VisibleForTesting;
-
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class AnonymousSignInHandler extends SingleProviderSignInHandler {
-
- @VisibleForTesting
- public FirebaseAuth mAuth;
-
- public AnonymousSignInHandler(Application application) {
- super(application, AuthUI.ANONYMOUS_PROVIDER);
- }
-
- @Override
- protected void onCreate() {
- mAuth = getAuth();
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- setResult(Resource.forLoading());
-
- // Calling signInAnonymously() will always return the same anonymous user if already
- // available. This is enforced by the client SDK.
- mAuth.signInAnonymously()
- .addOnSuccessListener(result -> setResult(Resource.forSuccess(initResponse(
- result.getAdditionalUserInfo().isNewUser()))))
- .addOnFailureListener(e -> setResult(Resource.forFailure(e)));
-
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {}
-
- private IdpResponse initResponse(boolean isNewUser) {
- return new IdpResponse.Builder(
- new User.Builder(AuthUI.ANONYMOUS_PROVIDER, null)
- .build())
- .setNewUser(isNewUser)
- .build();
- }
-
- // TODO: We need to centralize the auth logic. ProviderSignInBase classes were originally
- // meant to only retrieve remote provider data.
- private FirebaseAuth getAuth() {
- return AuthUI.getInstance(getArguments().appName).getAuth();
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/EmailSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/EmailSignInHandler.java
deleted file mode 100644
index d0b846468..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/EmailSignInHandler.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-import android.content.Intent;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.ui.email.EmailActivity;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.firebase.auth.EmailAuthCredential;
-import com.google.firebase.auth.EmailAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailSignInHandler extends SingleProviderSignInHandler {
- public EmailSignInHandler(Application application) {
- super(application, EmailAuthProvider.PROVIDER_ID);
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- activity.startActivityForResult(
- EmailActivity.createIntent(activity, activity.getFlowParams()),
- RequestCodes.EMAIL_FLOW);
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- if (resultCode == ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT) {
- // The activity deals with this case. This conflict is handled by the developer.
- } else if (requestCode == RequestCodes.EMAIL_FLOW) {
- IdpResponse response = IdpResponse.fromResultIntent(data);
- if (response == null) {
- setResult(Resource.forFailure(new UserCancellationException()));
- } else {
- setResult(Resource.forSuccess(response));
- }
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/FacebookSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/FacebookSignInHandler.java
deleted file mode 100644
index c89dfb9a7..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/FacebookSignInHandler.java
+++ /dev/null
@@ -1,167 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-
-import com.facebook.CallbackManager;
-import com.facebook.FacebookCallback;
-import com.facebook.FacebookException;
-import com.facebook.FacebookRequestError;
-import com.facebook.GraphRequest;
-import com.facebook.GraphResponse;
-import com.facebook.WebDialog;
-import com.facebook.login.LoginManager;
-import com.facebook.login.LoginResult;
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class FacebookSignInHandler extends SingleProviderSignInHandler {
- private static final String EMAIL = "email";
- private static final String PUBLIC_PROFILE = "public_profile";
-
- private List mPermissions;
-
- private final FacebookCallback mCallback = new Callback();
- private final CallbackManager mCallbackManager = CallbackManager.Factory.create();
-
- public FacebookSignInHandler(Application application) {
- super(application, FacebookAuthProvider.PROVIDER_ID);
- }
-
- private static IdpResponse createIdpResponse(
- LoginResult result, @Nullable String email, String name, Uri photoUri) {
- return new IdpResponse.Builder(
- new User.Builder(FacebookAuthProvider.PROVIDER_ID, email)
- .setName(name)
- .setPhotoUri(photoUri)
- .build())
- .setToken(result.getAccessToken().getToken())
- .build();
- }
-
- @Override
- protected void onCreate() {
- List permissions = getArguments().getParams()
- .getStringArrayList(ExtraConstants.FACEBOOK_PERMISSIONS);
- permissions = new ArrayList<>(
- permissions == null ? Collections.emptyList() : permissions);
-
- // Ensure we have email and public_profile permissions
- if (!permissions.contains(EMAIL)) { permissions.add(EMAIL); }
- if (!permissions.contains(PUBLIC_PROFILE)) { permissions.add(PUBLIC_PROFILE); }
-
- mPermissions = permissions;
-
- LoginManager.getInstance().registerCallback(mCallbackManager, mCallback);
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- WebDialog.setWebDialogTheme(activity.getFlowParams().themeId);
- LoginManager.getInstance().logInWithReadPermissions(activity, mPermissions);
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- mCallbackManager.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- protected void onCleared() {
- super.onCleared();
- LoginManager.getInstance().unregisterCallback(mCallbackManager);
- }
-
- private class Callback implements FacebookCallback {
- @Override
- public void onSuccess(LoginResult result) {
- setResult(Resource.forLoading());
-
- GraphRequest request = GraphRequest.newMeRequest(result.getAccessToken(),
- new ProfileRequest(result));
-
- Bundle parameters = new Bundle();
- parameters.putString("fields", "id,name,email,picture");
- request.setParameters(parameters);
- request.executeAsync();
- }
-
- @Override
- public void onCancel() {
- setResult(Resource.forFailure(new UserCancellationException()));
- }
-
- @Override
- public void onError(FacebookException e) {
- setResult(Resource.forFailure(new FirebaseUiException(
- ErrorCodes.PROVIDER_ERROR, e)));
- }
- }
-
- private class ProfileRequest implements GraphRequest.GraphJSONObjectCallback {
- private final LoginResult mResult;
-
- public ProfileRequest(LoginResult result) {
- mResult = result;
- }
-
- @Override
- public void onCompleted(JSONObject object, GraphResponse response) {
- FacebookRequestError error = response.getError();
- if (error != null) {
- setResult(Resource.forFailure(new FirebaseUiException(
- ErrorCodes.PROVIDER_ERROR, error.getException())));
- return;
- }
- if (object == null) {
- setResult(Resource.forFailure(new FirebaseUiException(
- ErrorCodes.PROVIDER_ERROR, "Facebook graph request failed")));
- return;
- }
-
- String email = null;
- String name = null;
- Uri photoUri = null;
-
- try {
- email = object.getString("email");
- } catch (JSONException ignored) {}
- try {
- name = object.getString("name");
- } catch (JSONException ignored) {}
- try {
- photoUri = Uri.parse(object.getJSONObject("picture")
- .getJSONObject("data")
- .getString("url"));
- } catch (JSONException ignored) {}
-
- setResult(Resource.forSuccess(createIdpResponse(mResult, email, name, photoUri)));
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpAnonymousUpgradeLinkingHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpAnonymousUpgradeLinkingHandler.java
deleted file mode 100644
index 7838c681c..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpAnonymousUpgradeLinkingHandler.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.util.data.AuthOperationManager;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.OAuthCredential;
-import com.google.firebase.auth.OAuthProvider;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class GenericIdpAnonymousUpgradeLinkingHandler extends GenericIdpSignInHandler {
-
- public GenericIdpAnonymousUpgradeLinkingHandler(Application application) {
- super(application);
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- setResult(Resource.forLoading());
-
- FlowParameters flowParameters = activity.getFlowParams();
- OAuthProvider provider = buildOAuthProvider(providerId, auth);
- if (flowParameters != null
- && AuthOperationManager.getInstance().canUpgradeAnonymous(auth, flowParameters)) {
- handleAnonymousUpgradeLinkingFlow(activity, provider, flowParameters);
- return;
- }
-
- handleNormalSignInFlow(auth, activity, provider);
- }
-
- private void handleAnonymousUpgradeLinkingFlow(final HelperActivityBase activity,
- final OAuthProvider provider,
- final FlowParameters flowParameters) {
- final boolean useEmulator = activity.getAuthUI().isUseEmulator();
- AuthOperationManager.getInstance().safeGenericIdpSignIn(activity, provider, flowParameters)
- .addOnSuccessListener(authResult -> {
- // Pass the credential so we can sign-in on the after the merge
- // conflict is resolved.
- handleSuccess(
- useEmulator,
- provider.getProviderId(),
- authResult.getUser(), (OAuthCredential) authResult.getCredential(),
- /* setPendingCredential= */true);
- })
- .addOnFailureListener(e -> setResult(Resource.forFailure(e)));
-
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpSignInHandler.java
deleted file mode 100644
index 13ba2b851..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/GenericIdpSignInHandler.java
+++ /dev/null
@@ -1,293 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.FirebaseUiUserCollisionException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-
-import android.app.Application;
-import android.content.Intent;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.VisibleForTesting;
-
-import com.firebase.ui.auth.AuthUI;
-
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.FirebaseAuthError;
-import com.firebase.ui.auth.util.data.AuthOperationManager;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.android.gms.tasks.OnFailureListener;
-import com.google.android.gms.tasks.OnSuccessListener;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseAuthException;
-import com.google.firebase.auth.FirebaseAuthUserCollisionException;
-import com.google.firebase.auth.FirebaseUser;
-import com.google.firebase.auth.GoogleAuthProvider;
-import com.google.firebase.auth.OAuthCredential;
-import com.google.firebase.auth.OAuthProvider;
-
-import java.util.HashMap;
-import java.util.List;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class GenericIdpSignInHandler extends ProviderSignInBase {
-
- public GenericIdpSignInHandler(Application application) {
- super(application);
- }
-
- @NonNull
- public static AuthUI.IdpConfig getGenericGoogleConfig() {
- return new AuthUI.IdpConfig.GenericOAuthProviderBuilder(
- GoogleAuthProvider.PROVIDER_ID,
- "Google",
- R.layout.fui_idp_button_google
- ).build();
- }
-
- @NonNull
- public static AuthUI.IdpConfig getGenericFacebookConfig() {
- return new AuthUI.IdpConfig.GenericOAuthProviderBuilder(
- FacebookAuthProvider.PROVIDER_ID,
- "Facebook",
- R.layout.fui_idp_button_facebook
- ).build();
- }
-
- @Override
- public final void startSignIn(@NonNull HelperActivityBase activity) {
- setResult(Resource.forLoading());
- startSignIn(activity.getAuth(), activity, getArguments().getProviderId());
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- setResult(Resource.forLoading());
-
- FlowParameters flowParameters = activity.getFlowParams();
- OAuthProvider provider = buildOAuthProvider(providerId, auth);
- if (flowParameters != null
- && AuthOperationManager.getInstance().canUpgradeAnonymous(auth, flowParameters)) {
- handleAnonymousUpgradeFlow(auth, activity, provider, flowParameters);
- return;
- }
-
- handleNormalSignInFlow(auth, activity, provider);
- }
-
- protected void handleNormalSignInFlow(final FirebaseAuth auth,
- final HelperActivityBase activity,
- final OAuthProvider provider) {
- final boolean useEmulator = activity.getAuthUI().isUseEmulator();
- auth.startActivityForSignInWithProvider(activity, provider)
- .addOnSuccessListener(
- authResult -> handleSuccess(
- useEmulator,
- provider.getProviderId(),
- authResult.getUser(),
- (OAuthCredential) authResult.getCredential(),
- authResult.getAdditionalUserInfo().isNewUser()))
- .addOnFailureListener(
- e -> {
- if (e instanceof FirebaseAuthException) {
- FirebaseAuthError error =
- FirebaseAuthError.fromException((FirebaseAuthException) e);
-
- if (e instanceof FirebaseAuthUserCollisionException) {
- FirebaseAuthUserCollisionException collisionException =
- (FirebaseAuthUserCollisionException) e;
-
- setResult(Resource.forFailure(
- new FirebaseUiUserCollisionException(
- ErrorCodes.ERROR_GENERIC_IDP_RECOVERABLE_ERROR,
- "Recoverable error.",
- provider.getProviderId(),
- collisionException.getEmail(),
- collisionException.getUpdatedCredential())));
- } else if (error == FirebaseAuthError.ERROR_WEB_CONTEXT_CANCELED) {
- setResult(Resource.forFailure(
- new UserCancellationException()));
- } else {
- setResult(Resource.forFailure(e));
- }
- } else {
- setResult(Resource.forFailure(e));
- }
- });
-
- }
-
-
- private void handleAnonymousUpgradeFlow(final FirebaseAuth auth,
- final HelperActivityBase activity,
- final OAuthProvider provider,
- final FlowParameters flowParameters) {
- final boolean useEmulator = activity.getAuthUI().isUseEmulator();
- auth.getCurrentUser()
- .startActivityForLinkWithProvider(activity, provider)
- .addOnSuccessListener(
- authResult -> handleSuccess(
- useEmulator,
- provider.getProviderId(),
- authResult.getUser(),
- (OAuthCredential) authResult.getCredential(),
- authResult.getAdditionalUserInfo().isNewUser()))
- .addOnFailureListener(
- e -> {
- if (!(e instanceof FirebaseAuthUserCollisionException)) {
- setResult(Resource.forFailure(e));
- return;
- }
-
- FirebaseAuthUserCollisionException collisionException =
- (FirebaseAuthUserCollisionException) e;
- final AuthCredential credential =
- collisionException.getUpdatedCredential();
- final String email =
- collisionException.getEmail();
-
- // Case 1: Anonymous user trying to link with an existing user
- // Case 2: Anonymous user trying to link with a provider keyed
- // by an email that already belongs to an existing account
- // (linking flow)
- ProviderUtils.fetchSortedProviders(auth, flowParameters, email)
- .addOnSuccessListener(providers -> {
- if (providers.isEmpty()) {
- String errorMessage =
- "Unable to complete the linkingflow -" +
- " the user is using " +
- "unsupported providers.";
- setResult(Resource.forFailure(
- new FirebaseUiException(
- ErrorCodes.DEVELOPER_ERROR,
- errorMessage)));
- return;
- }
-
- if (providers.contains(provider.getProviderId())) {
- // Case 1
- handleMergeFailure(credential);
- } else {
- // Case 2 - linking flow to be handled by
- // SocialProviderResponseHandler
- setResult(Resource.forFailure(
- new FirebaseUiUserCollisionException(
- ErrorCodes.ERROR_GENERIC_IDP_RECOVERABLE_ERROR,
- "Recoverable error.",
- provider.getProviderId(),
- email,
- credential)));
- }
- });
- });
- }
-
- public OAuthProvider buildOAuthProvider(String providerId, FirebaseAuth auth) {
- OAuthProvider.Builder providerBuilder =
- OAuthProvider.newBuilder(providerId, auth);
-
- List scopes =
- getArguments().getParams().getStringArrayList(ExtraConstants.GENERIC_OAUTH_SCOPES);
-
- // This unchecked cast is safe, this extra is put in as a serializable
- // in AuthUI.setCustomParameters
- HashMap customParams =
- (HashMap) getArguments().getParams()
- .getSerializable(ExtraConstants.GENERIC_OAUTH_CUSTOM_PARAMETERS);
-
- if (scopes != null) {
- providerBuilder.setScopes(scopes);
- }
- if (customParams != null) {
- providerBuilder.addCustomParameters(customParams);
- }
-
- return providerBuilder.build();
- }
-
- protected void handleSuccess(boolean isUseEmulator,
- @NonNull String providerId,
- @NonNull FirebaseUser user,
- @NonNull OAuthCredential credential,
- boolean isNewUser,
- boolean setPendingCredential) {
-
- String accessToken = credential.getAccessToken();
- // noinspection ConstantConditions
- if (accessToken == null && isUseEmulator) {
- accessToken = "fake_access_token";
- }
-
- String secret = credential.getSecret();
- if (secret == null && isUseEmulator) {
- secret = "fake_secret";
- }
-
- IdpResponse.Builder response = new IdpResponse.Builder(
- new User.Builder(
- providerId, user.getEmail())
- .setName(user.getDisplayName())
- .setPhotoUri(user.getPhotoUrl())
- .build())
- .setToken(accessToken)
- .setSecret(secret);
-
- if (setPendingCredential) {
- response.setPendingCredential(credential);
- }
- response.setNewUser(isNewUser);
-
- setResult(Resource.forSuccess(response.build()));
- }
-
- protected void handleSuccess(boolean isUseEmulator,
- @NonNull String providerId,
- @NonNull FirebaseUser user,
- @NonNull OAuthCredential credential,
- boolean isNewUser) {
- handleSuccess(isUseEmulator, providerId, user, credential, isNewUser, /* setPendingCredential= */true);
- }
-
-
- protected void handleMergeFailure(@NonNull AuthCredential credential) {
- IdpResponse failureResponse = new IdpResponse.Builder()
- .setPendingCredential(credential).build();
- setResult(Resource.forFailure(new FirebaseAuthAnonymousUpgradeException(
- ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT,
- failureResponse)));
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- if (requestCode == RequestCodes.GENERIC_IDP_SIGN_IN_FLOW) {
- IdpResponse response = IdpResponse.fromResultIntent(data);
- if (response == null) {
- setResult(Resource.forFailure(new UserCancellationException()));
- } else {
- setResult(Resource.forSuccess(response));
- }
- }
- }
-
- @VisibleForTesting
- public void initializeForTesting(AuthUI.IdpConfig idpConfig) {
- setArguments(idpConfig);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/GoogleSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/GoogleSignInHandler.java
deleted file mode 100644
index e494a4cb6..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/GoogleSignInHandler.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-import android.content.Intent;
-import android.text.TextUtils;
-import android.util.Log;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.IntentRequiredException;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.android.gms.auth.api.signin.GoogleSignIn;
-import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
-import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes;
-import com.google.android.gms.common.api.ApiException;
-import com.google.android.gms.common.api.CommonStatusCodes;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class GoogleSignInHandler extends SingleProviderSignInHandler {
- private static final String TAG = "GoogleSignInHandler";
-
- private AuthUI.IdpConfig mConfig;
- @Nullable private String mEmail;
-
- public GoogleSignInHandler(Application application) {
- super(application, GoogleAuthProvider.PROVIDER_ID);
- }
-
- private static IdpResponse createIdpResponse(GoogleSignInAccount account) {
- return new IdpResponse.Builder(
- new User.Builder(GoogleAuthProvider.PROVIDER_ID, account.getEmail())
- .setName(account.getDisplayName())
- .setPhotoUri(account.getPhotoUrl())
- .build())
- .setToken(account.getIdToken())
- .build();
- }
-
- @Override
- protected void onCreate() {
- Params params = getArguments();
- mConfig = params.config;
- mEmail = params.email;
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- start();
- }
-
- private void start() {
- setResult(Resource.forLoading());
- setResult(Resource.forFailure(new IntentRequiredException(
- GoogleSignIn.getClient(getApplication(), getSignInOptions()).getSignInIntent(),
- RequestCodes.GOOGLE_PROVIDER)));
- }
-
- private GoogleSignInOptions getSignInOptions() {
- GoogleSignInOptions.Builder builder = new GoogleSignInOptions.Builder(
- mConfig.getParams().getParcelable(
- ExtraConstants.GOOGLE_SIGN_IN_OPTIONS));
-
- if (!TextUtils.isEmpty(mEmail)) {
- builder.setAccountName(mEmail);
- }
-
- return builder.build();
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- if (requestCode != RequestCodes.GOOGLE_PROVIDER) { return; }
-
- try {
- GoogleSignInAccount account = GoogleSignIn.getSignedInAccountFromIntent(data)
- .getResult(ApiException.class);
- setResult(Resource.forSuccess(createIdpResponse(account)));
- } catch (ApiException e) {
- if (e.getStatusCode() == CommonStatusCodes.INVALID_ACCOUNT) {
- // If we get INVALID_ACCOUNT, it means the pre-set account was not available on the
- // device so set the email to null and launch the sign-in picker.
- mEmail = null;
- start();
- } else if (e.getStatusCode() == GoogleSignInStatusCodes.SIGN_IN_CURRENTLY_IN_PROGRESS) {
- // Hack for https://github.com/googlesamples/google-services/issues/345
- // Google remembers the account so the picker doesn't appear twice for the user.
- start();
- } else if (e.getStatusCode() == GoogleSignInStatusCodes.SIGN_IN_CANCELLED) {
- setResult(Resource.forFailure(new UserCancellationException()));
- } else {
- if (e.getStatusCode() == CommonStatusCodes.DEVELOPER_ERROR) {
- Log.w(TAG, "Developer error: this application is misconfigured. " +
- "Check your SHA1 and package name in the Firebase console.");
- }
- setResult(Resource.forFailure(new FirebaseUiException(
- ErrorCodes.PROVIDER_ERROR,
- "Code: " + e.getStatusCode() + ", message: " + e.getMessage())));
- }
- }
- }
-
- public static final class Params {
- private final AuthUI.IdpConfig config;
- @Nullable private final String email;
-
- public Params(AuthUI.IdpConfig config) {
- this(config, null);
- }
-
- public Params(AuthUI.IdpConfig config, @Nullable String email) {
- this.config = config;
- this.email = email;
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/PhoneSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/PhoneSignInHandler.java
deleted file mode 100644
index a93ee1b94..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/PhoneSignInHandler.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-import android.content.Intent;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.ui.phone.PhoneActivity;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.PhoneAuthProvider;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class PhoneSignInHandler extends SingleProviderSignInHandler {
- public PhoneSignInHandler(Application application) {
- super(application, PhoneAuthProvider.PROVIDER_ID);
- }
-
- @Override
- public void startSignIn(@NonNull FirebaseAuth auth,
- @NonNull HelperActivityBase activity,
- @NonNull String providerId) {
- activity.startActivityForResult(
- PhoneActivity.createIntent(
- activity, activity.getFlowParams(), getArguments().getParams()),
- RequestCodes.PHONE_FLOW);
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- if (requestCode == RequestCodes.PHONE_FLOW) {
- IdpResponse response = IdpResponse.fromResultIntent(data);
- if (response == null) {
- setResult(Resource.forFailure(new UserCancellationException()));
- } else {
- setResult(Resource.forSuccess(response));
- }
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/ProfileMerger.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/ProfileMerger.java
deleted file mode 100644
index 2eb19bd0c..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/ProfileMerger.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.net.Uri;
-import android.text.TextUtils;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.util.data.TaskFailureLogger;
-import com.google.android.gms.tasks.Continuation;
-import com.google.android.gms.tasks.Task;
-import com.google.android.gms.tasks.Tasks;
-import com.google.firebase.auth.AuthResult;
-import com.google.firebase.auth.FirebaseUser;
-import com.google.firebase.auth.UserProfileChangeRequest;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-/**
- * Merges an existing account's profile with the new user's profile.
- *
- * Note: This operation always returns a successful task to minimize login interruptions.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class ProfileMerger implements Continuation> {
- private static final String TAG = "ProfileMerger";
-
- private final IdpResponse mResponse;
-
- public ProfileMerger(IdpResponse response) {
- mResponse = response;
- }
-
- @Override
- public Task then(@NonNull Task task) {
- final AuthResult authResult = task.getResult();
- FirebaseUser firebaseUser = authResult.getUser();
-
- String name = firebaseUser.getDisplayName();
- Uri photoUri = firebaseUser.getPhotoUrl();
- if (!TextUtils.isEmpty(name) && photoUri != null) {
- return Tasks.forResult(authResult);
- }
-
- User user = mResponse.getUser();
- if (TextUtils.isEmpty(name)) { name = user.getName(); }
- if (photoUri == null) { photoUri = user.getPhotoUri(); }
-
- return firebaseUser.updateProfile(
- new UserProfileChangeRequest.Builder()
- .setDisplayName(name)
- .setPhotoUri(photoUri)
- .build())
- .addOnFailureListener(new TaskFailureLogger(TAG, "Error updating profile"))
- .continueWithTask(task1 -> Tasks.forResult(authResult));
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/SignInKickstarter.kt b/auth/src/main/java/com/firebase/ui/auth/data/remote/SignInKickstarter.kt
deleted file mode 100644
index b665ce1d4..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/SignInKickstarter.kt
+++ /dev/null
@@ -1,247 +0,0 @@
-package com.firebase.ui.auth.data.remote
-
-import android.app.Activity
-import android.app.Application
-import android.content.Intent
-import android.os.Bundle
-import android.text.TextUtils
-import android.util.Log
-import com.firebase.ui.auth.AuthUI
-import com.firebase.ui.auth.ErrorCodes
-import com.firebase.ui.auth.IdpResponse
-import com.firebase.ui.auth.data.model.IntentRequiredException
-import com.firebase.ui.auth.data.model.Resource
-import com.firebase.ui.auth.data.model.User
-import com.firebase.ui.auth.data.model.UserCancellationException
-import com.firebase.ui.auth.ui.email.EmailActivity
-import com.firebase.ui.auth.ui.email.EmailLinkCatcherActivity
-import com.firebase.ui.auth.ui.idp.AuthMethodPickerActivity
-import com.firebase.ui.auth.ui.idp.SingleSignInActivity
-import com.firebase.ui.auth.ui.phone.PhoneActivity
-import com.firebase.ui.auth.util.ExtraConstants
-import com.firebase.ui.auth.viewmodel.RequestCodes
-import com.firebase.ui.auth.viewmodel.SignInViewModelBase
-import com.google.android.gms.auth.api.identity.Identity
-import com.google.android.gms.auth.api.identity.SignInCredential
-import com.google.android.gms.common.api.ApiException
-import com.google.firebase.auth.AuthResult
-import com.google.firebase.auth.EmailAuthProvider
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
-import com.google.firebase.auth.FirebaseAuthInvalidUserException
-import com.google.firebase.auth.GoogleAuthProvider
-import com.google.firebase.auth.PhoneAuthProvider
-import kotlinx.coroutines.launch
-import androidx.lifecycle.viewModelScope
-
-import androidx.credentials.Credential
-import androidx.credentials.CustomCredential
-import androidx.credentials.GetCredentialRequest
-import androidx.credentials.GetPasswordOption
-import androidx.credentials.PasswordCredential
-import androidx.credentials.PublicKeyCredential
-import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
-import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
-
-
-private const val TAG = "SignInKickstarter"
-
-class SignInKickstarter(application: Application?) : SignInViewModelBase(application) {
-
- private val app: Application = checkNotNull(application)
-
- /**
- * Entry point. If an email link is detected, immediately launch the email catcher.
- * Otherwise, launch startAuthMethodChoice.
- */
- fun start() {
- if (!TextUtils.isEmpty(arguments.emailLink)) {
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- EmailLinkCatcherActivity.createIntent(app, arguments),
- RequestCodes.EMAIL_FLOW
- )
- )
- )
- return
- }
- startAuthMethodChoice()
- }
-
-
- /**
- * Fallback: if no credential was obtained (or after a failed Credential Manager attempt)
- * choose the proper sign‑in flow.
- */
- private fun startAuthMethodChoice() {
- if (!arguments.shouldShowProviderChoice()) {
- val firstIdpConfig = arguments.defaultOrFirstProvider
- val firstProvider = firstIdpConfig.providerId
- when (firstProvider) {
- AuthUI.EMAIL_LINK_PROVIDER, EmailAuthProvider.PROVIDER_ID ->
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- EmailActivity.createIntent(app, arguments),
- RequestCodes.EMAIL_FLOW
- )
- )
- )
- PhoneAuthProvider.PROVIDER_ID ->
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- PhoneActivity.createIntent(app, arguments, firstIdpConfig.params),
- RequestCodes.PHONE_FLOW
- )
- )
- )
- else -> redirectSignIn(firstProvider, null)
- }
- } else {
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- AuthMethodPickerActivity.createIntent(app, arguments),
- RequestCodes.AUTH_PICKER_FLOW
- )
- )
- )
- }
- }
-
- /**
- * Helper to route to the proper sign‑in activity for a given provider.
- */
- private fun redirectSignIn(provider: String, id: String?) {
- when (provider) {
- EmailAuthProvider.PROVIDER_ID ->
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- EmailActivity.createIntent(app, arguments, id),
- RequestCodes.EMAIL_FLOW
- )
- )
- )
- PhoneAuthProvider.PROVIDER_ID -> {
- val args = Bundle().apply { putString(ExtraConstants.PHONE, id) }
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- PhoneActivity.createIntent(app, arguments, args),
- RequestCodes.PHONE_FLOW
- )
- )
- )
- }
- else ->
- setResult(
- Resource.forFailure(
- IntentRequiredException(
- SingleSignInActivity.createIntent(
- app, arguments, User.Builder(provider, id).build()
- ),
- RequestCodes.PROVIDER_FLOW
- )
- )
- )
- }
- }
-
- /**
- * Legacy onActivityResult handler for other flows.
- */
- fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- when (requestCode) {
- RequestCodes.EMAIL_FLOW,
- RequestCodes.AUTH_PICKER_FLOW,
- RequestCodes.PHONE_FLOW,
- RequestCodes.PROVIDER_FLOW -> {
- if (resultCode == RequestCodes.EMAIL_LINK_WRONG_DEVICE_FLOW ||
- resultCode == RequestCodes.EMAIL_LINK_INVALID_LINK_FLOW
- ) {
- startAuthMethodChoice()
- return
- }
- val response = IdpResponse.fromResultIntent(data)
- if (response == null) {
- setResult(Resource.forFailure(UserCancellationException()))
- } else if (response.isSuccessful) {
- setResult(Resource.forSuccess(response))
- } else if (response.error!!.errorCode == ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT) {
- handleMergeFailure(response)
- } else {
- setResult(Resource.forFailure(response.error!!))
- }
- }
- else -> startAuthMethodChoice()
- }
- }
-
- /**
- * Handle a successfully returned Credential from the Credential Manager.
- */
- private fun handleCredentialManagerResult(credential: Credential) {
- when (credential) {
- is PasswordCredential -> {
- val username = credential.id
- val password = credential.password
- val response = IdpResponse.Builder(
- User.Builder(EmailAuthProvider.PROVIDER_ID, username).build()
- ).build()
- setResult(Resource.forLoading())
- auth.signInWithEmailAndPassword(username, password)
- .addOnSuccessListener { authResult: AuthResult ->
- handleSuccess(response, authResult)
- // (Optionally finish the hosting activity here.)
- }
- .addOnFailureListener { e ->
- if (e is FirebaseAuthInvalidUserException ||
- e is FirebaseAuthInvalidCredentialsException
- ) {
- // Sign out using the new API.
- Identity.getSignInClient(app).signOut()
- }
- startAuthMethodChoice()
- }
- }
- is CustomCredential -> {
- if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
- try {
- val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
- auth.signInWithCredential(
- GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null)
- )
- .addOnSuccessListener { authResult: AuthResult ->
- val response = IdpResponse.Builder(
- User.Builder(
- GoogleAuthProvider.PROVIDER_ID,
- // Assume the credential data contains the email.
- googleIdTokenCredential.data.getString("email")
- ).build()
- )
- .setToken(googleIdTokenCredential.idToken)
- .build()
- handleSuccess(response, authResult)
- }
- .addOnFailureListener { e ->
- Log.e(TAG, "Failed to sign in with Google ID token", e)
- startAuthMethodChoice()
- }
- } catch (e: GoogleIdTokenParsingException) {
- Log.e(TAG, "Received an invalid google id token response", e)
- startAuthMethodChoice()
- }
- } else {
- Log.e(TAG, "Unexpected type of credential")
- startAuthMethodChoice()
- }
- }
- else -> {
- Log.e(TAG, "Unexpected type of credential")
- startAuthMethodChoice()
- }
- }
- }
-}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/data/remote/SingleProviderSignInHandler.java b/auth/src/main/java/com/firebase/ui/auth/data/remote/SingleProviderSignInHandler.java
deleted file mode 100644
index 502b7bbee..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/data/remote/SingleProviderSignInHandler.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.firebase.ui.auth.data.remote;
-
-import android.app.Application;
-
-import com.firebase.ui.auth.ui.HelperActivityBase;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public abstract class SingleProviderSignInHandler extends ProviderSignInBase {
-
- private final String mProviderId;
-
- protected SingleProviderSignInHandler(Application application, String providerId) {
- super(application);
- this.mProviderId = providerId;
- }
-
- @Override
- public final void startSignIn(@NonNull HelperActivityBase activity) {
- this.startSignIn(activity.getAuth(), activity, mProviderId);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt
new file mode 100644
index 000000000..4311a7926
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaChallengeContentState.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+
+/**
+ * State class containing all the necessary information to render a custom UI for the
+ * Multi-Factor Authentication (MFA) challenge flow during sign-in.
+ *
+ * This class is passed to the content slot of the MfaChallengeScreen composable, providing
+ * access to the current factor, user input values, callbacks for actions, and loading/error states.
+ *
+ * The challenge flow is simpler than enrollment as the user has already configured their MFA:
+ * 1. User enters their verification code (SMS or TOTP)
+ * 2. System verifies the code and completes sign-in
+ *
+ * ```kotlin
+ * MfaChallengeScreen(resolver, onSuccess, onCancel, onError) { state ->
+ * Column {
+ * Text("Enter your ${state.factorType} code")
+ * TextField(
+ * value = state.verificationCode,
+ * onValueChange = state.onVerificationCodeChange
+ * )
+ * if (state.canResend) {
+ * TextButton(onClick = state.onResendCodeClick) {
+ * Text("Resend code")
+ * }
+ * }
+ * Button(
+ * onClick = state.onVerifyClick,
+ * enabled = !state.isLoading && state.isValid
+ * ) {
+ * Text("Verify")
+ * }
+ * }
+ * }
+ * ```
+ *
+ * @property factorType The type of MFA factor being challenged (SMS or TOTP)
+ * @property maskedPhoneNumber For SMS factors, the masked phone number (e.g., "+1••••••890")
+ * @property isLoading `true` when verification is in progress. Use this to show loading indicators.
+ * @property error An optional error message to display to the user. Will be `null` if there's no error.
+ * @property verificationCode The current value of the verification code input field.
+ * @property resendTimer The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed.
+ * @property onVerificationCodeChange Callback invoked when the verification code input changes.
+ * @property onVerifyClick Callback to verify the entered code and complete sign-in.
+ * @property onResendCodeClick For SMS only: Callback to resend the verification code. `null` for TOTP.
+ * @property onCancelClick Callback to cancel the MFA challenge and return to sign-in.
+ *
+ * @since 10.0.0
+ */
+data class MfaChallengeContentState(
+ /** The type of MFA factor being challenged (SMS or TOTP). */
+ val factorType: MfaFactor,
+
+ /** For SMS: the masked phone number. For TOTP: null. */
+ val maskedPhoneNumber: String? = null,
+
+ /** `true` when verification is in progress. Use to show loading indicators. */
+ val isLoading: Boolean = false,
+
+ /** Optional error message to display. `null` if no error. */
+ val error: String? = null,
+
+ /** The current value of the verification code input field. */
+ val verificationCode: String = "",
+
+ /** The number of seconds remaining before resend is available. 0 when ready. */
+ val resendTimer: Int = 0,
+
+ /** Callback invoked when the verification code input changes. */
+ val onVerificationCodeChange: (String) -> Unit = {},
+
+ /** Callback to verify the code and complete sign-in. */
+ val onVerifyClick: () -> Unit = {},
+
+ /** For SMS only: Callback to resend the code. `null` for TOTP. */
+ val onResendCodeClick: (() -> Unit)? = null,
+
+ /** Callback to cancel the challenge and return to sign-in. */
+ val onCancelClick: () -> Unit = {}
+) {
+ /**
+ * Returns true if the current state is valid for verification.
+ * The code must be 6 digits long.
+ */
+ val isValid: Boolean
+ get() = verificationCode.length == 6 && verificationCode.all { it.isDigit() }
+
+ /**
+ * Returns true if there is an error in the current state.
+ */
+ val hasError: Boolean
+ get() = !error.isNullOrBlank()
+
+ /**
+ * Returns true if the resend action is available (SMS only).
+ */
+ val canResend: Boolean
+ get() = factorType == MfaFactor.Sms && onResendCodeClick != null
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
new file mode 100644
index 000000000..674cb42e6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentContentState.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.data.CountryData
+import com.google.firebase.auth.MultiFactorInfo
+
+/**
+ * State class containing all the necessary information to render a custom UI for the
+ * Multi-Factor Authentication (MFA) enrollment flow.
+ *
+ * This class is passed to the content slot of the MfaEnrollmentScreen composable, providing
+ * access to the current step, user input values, callbacks for actions, and loading/error states.
+ *
+ * Use a `when` expression on [step] to determine which UI to render:
+ *
+ * ```kotlin
+ * MfaEnrollmentScreen(user, config, onComplete, onSkip) { state ->
+ * when (state.step) {
+ * MfaEnrollmentStep.SelectFactor -> {
+ * // Render factor selection UI using state.availableFactors
+ * }
+ * MfaEnrollmentStep.ConfigureTotp -> {
+ * // Render TOTP setup UI using state.totpSecret and state.totpQrCodeUrl
+ * }
+ * MfaEnrollmentStep.VerifyFactor -> {
+ * // Render verification UI using state.verificationCode
+ * }
+ * // ... other steps
+ * }
+ * }
+ * ```
+ *
+ * @property step The current step in the enrollment flow. Use this to determine which UI to display.
+ * @property isLoading `true` when an asynchronous operation (like generating a secret or verifying a code) is in progress. Use this to show loading indicators.
+ * @property error An optional error message to display to the user. Will be `null` if there's no error.
+ * @property onBackClick Callback to navigate to the previous step in the flow. Invoked when the user clicks a back button.
+ *
+ * @property availableFactors (Step: [MfaEnrollmentStep.SelectFactor]) A list of MFA factors the user can choose from (e.g., SMS, TOTP). Determined by [com.firebase.ui.auth.configuration.MfaConfiguration.allowedFactors].
+ * @property onFactorSelected (Step: [MfaEnrollmentStep.SelectFactor]) Callback invoked when the user selects an MFA factor. Receives the selected [MfaFactor].
+ * @property onSkipClick (Step: [MfaEnrollmentStep.SelectFactor]) Callback for the "Skip" action. Will be `null` if MFA enrollment is required via [com.firebase.ui.auth.configuration.MfaConfiguration.requireEnrollment].
+ *
+ * @property phoneNumber (Step: [MfaEnrollmentStep.ConfigureSms]) The current value of the phone number input field. Does not include country code prefix.
+ * @property onPhoneNumberChange (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the phone number input changes. Receives the new phone number string.
+ * @property selectedCountry (Step: [MfaEnrollmentStep.ConfigureSms]) The currently selected country for phone number formatting. Contains dial code, country code, and flag.
+ * @property onCountrySelected (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the user selects a different country. Receives the new [CountryData].
+ * @property onSendSmsCodeClick (Step: [MfaEnrollmentStep.ConfigureSms]) Callback to send the SMS verification code to the entered phone number.
+ *
+ * @property totpSecret (Step: [MfaEnrollmentStep.ConfigureTotp]) The TOTP secret containing the shared key and configuration. Use this to display the secret key or access the underlying Firebase TOTP secret.
+ * @property totpQrCodeUrl (Step: [MfaEnrollmentStep.ConfigureTotp]) A URI that can be rendered as a QR code or used as a deep link to open authenticator apps. Generated via [TotpSecret.generateQrCodeUrl].
+ * @property onContinueToVerifyClick (Step: [MfaEnrollmentStep.ConfigureTotp]) Callback to proceed to the verification step after the user has scanned the QR code or entered the secret.
+ *
+ * @property verificationCode (Step: [MfaEnrollmentStep.VerifyFactor]) The current value of the verification code input field. Should be a 6-digit string.
+ * @property onVerificationCodeChange (Step: [MfaEnrollmentStep.VerifyFactor]) Callback invoked when the verification code input changes. Receives the new code string.
+ * @property onVerifyClick (Step: [MfaEnrollmentStep.VerifyFactor]) Callback to verify the entered code and finalize MFA enrollment.
+ * @property selectedFactor (Step: [MfaEnrollmentStep.VerifyFactor]) The MFA factor being verified (SMS or TOTP). Use this to customize UI messages.
+ * @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed.
+ * @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification.
+ *
+ * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.configuration.MfaConfiguration.enableRecoveryCodes] is `true`.
+ * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow.
+ *
+ * @since 10.0.0
+ */
+data class MfaEnrollmentContentState(
+ /** The current step in the enrollment flow. Use this to determine which UI to display. */
+ val step: MfaEnrollmentStep,
+
+ /** `true` when an async operation is in progress. Use to show loading indicators. */
+ val isLoading: Boolean = false,
+
+ /** Optional error message to display. `null` if no error. */
+ val error: String? = null,
+
+ /** The last exception encountered during enrollment, if available. */
+ val exception: Exception? = null,
+
+ /** Callback to navigate to the previous step. */
+ val onBackClick: () -> Unit = {},
+
+ // SelectFactor step
+ val availableFactors: List = emptyList(),
+
+ val enrolledFactors: List = emptyList(),
+
+ val onFactorSelected: (MfaFactor) -> Unit = {},
+
+ val onUnenrollFactor: (MultiFactorInfo) -> Unit = {},
+
+ val onSkipClick: (() -> Unit)? = null,
+
+ // ConfigureSms step
+ val phoneNumber: String = "",
+
+ val onPhoneNumberChange: (String) -> Unit = {},
+
+ val selectedCountry: CountryData? = null,
+
+ val onCountrySelected: (CountryData) -> Unit = {},
+
+ val onSendSmsCodeClick: () -> Unit = {},
+
+ // ConfigureTotp step
+ val totpSecret: TotpSecret? = null,
+
+ val totpQrCodeUrl: String? = null,
+
+ val onContinueToVerifyClick: () -> Unit = {},
+
+ // VerifyFactor step
+ val verificationCode: String = "",
+
+ val onVerificationCodeChange: (String) -> Unit = {},
+
+ val onVerifyClick: () -> Unit = {},
+
+ val selectedFactor: MfaFactor? = null,
+
+ val resendTimer: Int = 0,
+
+ val onResendCodeClick: (() -> Unit)? = null,
+
+ // ShowRecoveryCodes step
+ val recoveryCodes: List? = null,
+
+ val onCodesSavedClick: () -> Unit = {}
+) {
+ /**
+ * Returns true if the current state is valid for the current step.
+ *
+ * This can be used to enable/disable action buttons in the UI.
+ */
+ val isValid: Boolean
+ get() = when (step) {
+ MfaEnrollmentStep.SelectFactor -> availableFactors.isNotEmpty()
+ MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank()
+ MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null
+ MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6
+ MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty()
+ }
+
+ /**
+ * Returns true if there is an error in the current state.
+ */
+ val hasError: Boolean
+ get() = !error.isNullOrBlank()
+
+ /**
+ * Returns true if the skip action is available (only for SelectFactor step when not required).
+ */
+ val canSkip: Boolean
+ get() = step == MfaEnrollmentStep.SelectFactor && onSkipClick != null
+
+ /**
+ * Returns true if the back action is available (for all steps except SelectFactor).
+ */
+ val canGoBack: Boolean
+ get() = step != MfaEnrollmentStep.SelectFactor
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
new file mode 100644
index 000000000..8d64da620
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaEnrollmentStep.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow.
+ *
+ * This enum defines the sequence of UI states that users progress through when enrolling
+ * in MFA, from selecting a factor to completing the setup with recovery codes.
+ *
+ * @since 10.0.0
+ */
+enum class MfaEnrollmentStep {
+ /**
+ * The user is presented with a selection of available MFA factors to enroll in.
+ * The available factors are determined by the [com.firebase.ui.auth.configuration.MfaConfiguration].
+ */
+ SelectFactor,
+
+ /**
+ * The user is configuring SMS-based MFA by entering their phone number.
+ * This step prepares to send an SMS verification code to the provided number.
+ */
+ ConfigureSms,
+
+ /**
+ * The user is configuring TOTP (Time-based One-Time Password) MFA.
+ * This step presents the TOTP secret (as both text and QR code) for the user
+ * to scan into their authenticator app.
+ */
+ ConfigureTotp,
+
+ /**
+ * The user is verifying their chosen MFA factor by entering a verification code.
+ * For SMS, this is the code received via text message.
+ * For TOTP, this is the code generated by their authenticator app.
+ */
+ VerifyFactor,
+
+ /**
+ * The enrollment is complete and recovery codes are displayed to the user.
+ * These backup codes can be used to sign in if the primary MFA method is unavailable.
+ * This step only appears if recovery codes are enabled in the configuration.
+ */
+ ShowRecoveryCodes
+}
+
+/**
+ * Returns the localized title text for this enrollment step.
+ *
+ * @param stringProvider The string provider for localized strings
+ * @return The localized title for this step
+ */
+fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = when (this) {
+ MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorTitle
+ MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle
+ MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle
+ MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle
+ MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle
+}
+
+/**
+ * Returns localized helper text providing instructions for this step.
+ *
+ * @param stringProvider The string provider for localized strings
+ * @param selectedFactor The MFA factor being configured or verified. Used for [MfaEnrollmentStep.VerifyFactor]
+ * to provide factor-specific instructions. Ignored for other steps.
+ * @return Localized instructional text appropriate for this step
+ */
+fun MfaEnrollmentStep.getHelperText(
+ stringProvider: AuthUIStringProvider,
+ selectedFactor: MfaFactor? = null
+): String = when (this) {
+ MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorHelper
+ MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsHelper
+ MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper
+ MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) {
+ MfaFactor.Sms -> stringProvider.mfaStepVerifyFactorSmsHelper
+ MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper
+ null -> stringProvider.mfaStepVerifyFactorGenericHelper
+ }
+ MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt
new file mode 100644
index 000000000..7776702ee
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/MfaErrorMapper.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.google.firebase.FirebaseNetworkException
+import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
+import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
+import java.io.IOException
+
+/**
+ * Maps Firebase Auth exceptions to localized error messages for MFA enrollment.
+ *
+ * @param stringProvider Provider for localized strings
+ * @return Localized error message appropriate for the exception type
+ */
+fun Exception.toMfaErrorMessage(stringProvider: AuthUIStringProvider): String {
+ return when (this) {
+ is FirebaseAuthRecentLoginRequiredException ->
+ stringProvider.mfaErrorRecentLoginRequired
+ is FirebaseAuthInvalidCredentialsException ->
+ stringProvider.mfaErrorInvalidVerificationCode
+ is IOException, is FirebaseNetworkException ->
+ stringProvider.mfaErrorNetwork
+ else -> stringProvider.mfaErrorGeneric
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt
new file mode 100644
index 000000000..8aec7c9c3
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt
@@ -0,0 +1,376 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import android.app.Activity
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.PhoneAuthCredential
+import com.google.firebase.auth.PhoneAuthProvider
+import com.google.firebase.auth.PhoneMultiFactorGenerator
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Handler for SMS multi-factor authentication enrollment.
+ *
+ * This class manages the complete SMS enrollment flow, including:
+ * - Sending SMS verification codes to phone numbers
+ * - Resending codes with timer support
+ * - Verifying SMS codes entered by users
+ * - Finalizing enrollment with Firebase Authentication
+ *
+ * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure
+ * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow.
+ *
+ * **Usage:**
+ * ```kotlin
+ * val handler = SmsEnrollmentHandler(auth, user)
+ *
+ * // Step 1: Send verification code
+ * val session = handler.sendVerificationCode("+1234567890")
+ *
+ * // Step 2: Display masked phone number and wait for user input
+ * val masked = session.getMaskedPhoneNumber()
+ *
+ * // Step 3: If needed, resend code after timer expires
+ * val newSession = handler.resendVerificationCode(session)
+ *
+ * // Step 4: Verify the code entered by the user
+ * val verificationCode = "123456" // From user input
+ * handler.enrollWithVerificationCode(session, verificationCode, "My Phone")
+ * ```
+ *
+ * @property auth The [FirebaseAuth] instance
+ * @property user The [FirebaseUser] to enroll in SMS MFA
+ *
+ * @since 10.0.0
+ * @see TotpEnrollmentHandler
+ * @see AuthProvider.Phone.verifyPhoneNumberAwait
+ */
+class SmsEnrollmentHandler(
+ private val activity: Activity,
+ private val auth: FirebaseAuth,
+ private val user: FirebaseUser
+) {
+ private val phoneProvider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ smsCodeLength = SMS_CODE_LENGTH,
+ timeout = VERIFICATION_TIMEOUT_SECONDS,
+ isInstantVerificationEnabled = true
+ )
+ /**
+ * Sends an SMS verification code to the specified phone number.
+ *
+ * This method initiates the SMS enrollment process by sending a verification code
+ * to the provided phone number. The code will be sent via SMS and should be
+ * displayed to the user for entry.
+ *
+ * **Important:** The user must re-authenticate before calling this method if their
+ * session is not recent. Use [FirebaseUser.reauthenticate] if needed.
+ *
+ * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890")
+ * @return An [SmsEnrollmentSession] containing the verification ID and metadata
+ * @throws Exception if the user needs to re-authenticate, phone number is invalid,
+ * or SMS sending fails
+ *
+ * @see resendVerificationCode
+ * @see SmsEnrollmentSession.getMaskedPhoneNumber
+ */
+ suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession {
+ require(isValidPhoneNumber(phoneNumber)) {
+ "Phone number must be in E.164 format (e.g., +1234567890)"
+ }
+
+ val multiFactorSession = user.multiFactor.session.await()
+ val result = phoneProvider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = null
+ )
+
+ return when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ SmsEnrollmentSession(
+ verificationId = "", // Not needed when auto-verified
+ phoneNumber = phoneNumber,
+ forceResendingToken = null,
+ sentAt = System.currentTimeMillis(),
+ autoVerifiedCredential = result.credential
+ )
+ }
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ SmsEnrollmentSession(
+ verificationId = result.verificationId,
+ phoneNumber = phoneNumber,
+ forceResendingToken = result.token,
+ sentAt = System.currentTimeMillis()
+ )
+ }
+ }
+ }
+
+ /**
+ * Resends the SMS verification code to the phone number.
+ *
+ * This method uses the force resending token from the original session to
+ * explicitly request a new SMS code. This should only be called after the
+ * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits.
+ *
+ * @param session The original [SmsEnrollmentSession] from [sendVerificationCode]
+ * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp
+ * @throws Exception if resending fails or if the session doesn't have a resend token
+ *
+ * @see sendVerificationCode
+ */
+ suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession {
+ require(session.forceResendingToken != null) {
+ "Cannot resend code without a force resending token"
+ }
+
+ val multiFactorSession = user.multiFactor.session.await()
+ val result = phoneProvider.verifyPhoneNumberAwait(
+ auth = auth,
+ activity = activity,
+ phoneNumber = session.phoneNumber,
+ multiFactorSession = multiFactorSession,
+ forceResendingToken = session.forceResendingToken
+ )
+
+ return when (result) {
+ is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> {
+ SmsEnrollmentSession(
+ verificationId = "", // Not needed when auto-verified
+ phoneNumber = session.phoneNumber,
+ forceResendingToken = session.forceResendingToken,
+ sentAt = System.currentTimeMillis(),
+ autoVerifiedCredential = result.credential
+ )
+ }
+ is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> {
+ SmsEnrollmentSession(
+ verificationId = result.verificationId,
+ phoneNumber = session.phoneNumber,
+ forceResendingToken = result.token,
+ sentAt = System.currentTimeMillis()
+ )
+ }
+ }
+ }
+
+ /**
+ * Verifies an SMS code and completes the enrollment process.
+ *
+ * This method creates a multi-factor assertion using the provided session and
+ * verification code, then enrolls the user in SMS MFA with Firebase Authentication.
+ *
+ * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode]
+ * @param verificationCode The 6-digit code from the SMS message
+ * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone")
+ * @throws Exception if the verification code is invalid or if enrollment fails
+ *
+ * @see sendVerificationCode
+ * @see resendVerificationCode
+ */
+ suspend fun enrollWithVerificationCode(
+ session: SmsEnrollmentSession,
+ verificationCode: String,
+ displayName: String? = null
+ ) {
+ require(isValidCodeFormat(verificationCode)) {
+ "Verification code must be 6 digits"
+ }
+
+ val credential = session.autoVerifiedCredential
+ ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode)
+
+ val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential)
+ user.multiFactor.enroll(multiFactorAssertion, displayName).await()
+ }
+
+ /**
+ * Validates that a verification code has the correct format for SMS.
+ *
+ * This method performs basic client-side validation to ensure the code:
+ * - Is not null or empty
+ * - Contains only digits
+ * - Has exactly 6 digits (the standard SMS code length)
+ *
+ * **Note:** This does not verify the code against the server. Use
+ * [enrollWithVerificationCode] to perform actual verification with Firebase.
+ *
+ * @param code The verification code to validate
+ * @return `true` if the code has a valid format, `false` otherwise
+ */
+ fun isValidCodeFormat(code: String): Boolean {
+ return code.isNotBlank() &&
+ code.length == SMS_CODE_LENGTH &&
+ code.all { it.isDigit() }
+ }
+
+ /**
+ * Validates that a phone number is in the correct E.164 format.
+ *
+ * E.164 format requirements:
+ * - Starts with "+"
+ * - Followed by 1-15 digits
+ * - No spaces, hyphens, or other characters
+ * - Minimum 4 digits total (country code + subscriber number)
+ *
+ * Examples of valid numbers:
+ * - +1234567890 (US)
+ * - +447911123456 (UK)
+ * - +33612345678 (France)
+ *
+ * @param phoneNumber The phone number to validate
+ * @return `true` if the phone number is in E.164 format, `false` otherwise
+ */
+ fun isValidPhoneNumber(phoneNumber: String): Boolean {
+ return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$"))
+ }
+
+ companion object {
+ /**
+ * The standard length for SMS verification codes.
+ */
+ const val SMS_CODE_LENGTH = 6
+
+ /**
+ * The verification timeout in seconds for phone authentication.
+ * This is how long Firebase will wait for auto-verification before
+ * falling back to manual code entry.
+ */
+ const val VERIFICATION_TIMEOUT_SECONDS = 60L
+
+ /**
+ * The recommended delay in seconds before allowing code resend.
+ * This prevents users from spamming the resend functionality and
+ * respects carrier rate limits.
+ */
+ const val RESEND_DELAY_SECONDS = 30
+
+ /**
+ * The Firebase factor ID for SMS multi-factor authentication.
+ */
+ const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID
+ }
+}
+
+/**
+ * Represents an active SMS enrollment session with verification state.
+ *
+ * This class holds all the information needed to complete an SMS enrollment,
+ * including the verification ID, phone number, and resend token.
+ *
+ * @property verificationId The verification ID from Firebase
+ * @property phoneNumber The phone number being verified in E.164 format
+ * @property forceResendingToken Optional token for resending the SMS code
+ * @property sentAt Timestamp in milliseconds when the code was sent
+ * @property autoVerifiedCredential Optional credential if auto-verification succeeded
+ *
+ * @since 10.0.0
+ */
+data class SmsEnrollmentSession(
+ val verificationId: String,
+ val phoneNumber: String,
+ val forceResendingToken: PhoneAuthProvider.ForceResendingToken?,
+ val sentAt: Long,
+ val autoVerifiedCredential: PhoneAuthCredential? = null
+) {
+ /**
+ * Returns a masked version of the phone number for display purposes.
+ *
+ * Masks the middle digits of the phone number while keeping the country code
+ * and last few digits visible for user confirmation.
+ *
+ * Examples:
+ * - "+1234567890" → "+1••••••890"
+ * - "+447911123456" → "+44•••••••456"
+ *
+ * @return The masked phone number string
+ */
+ fun getMaskedPhoneNumber(): String {
+ return maskPhoneNumber(phoneNumber)
+ }
+
+ /**
+ * Checks if the resend delay has elapsed since the code was sent.
+ *
+ * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
+ * @return `true` if enough time has passed to allow resending
+ */
+ fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean {
+ val elapsed = (System.currentTimeMillis() - sentAt) / 1000
+ return elapsed >= delaySec
+ }
+
+ /**
+ * Returns the remaining seconds until resend is allowed.
+ *
+ * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS])
+ * @return The number of seconds remaining, or 0 if resend is already allowed
+ */
+ fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int {
+ val elapsed = (System.currentTimeMillis() - sentAt) / 1000
+ return maxOf(0, delaySec - elapsed.toInt())
+ }
+}
+
+/**
+ * Masks the middle digits of a phone number for privacy.
+ *
+ * The function keeps the country code (first 1-3 characters after +) and
+ * the last 2-4 digits visible, masking everything in between with bullets.
+ * Longer phone numbers show more last digits for better user confirmation.
+ *
+ * Examples:
+ * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits)
+ * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits)
+ * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits)
+ * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits)
+ *
+ * @param phoneNumber The phone number to mask in E.164 format
+ * @return The masked phone number string
+ */
+fun maskPhoneNumber(phoneNumber: String): String {
+ if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) {
+ return phoneNumber
+ }
+
+ // Determine country code length (typically 1-3 digits after +)
+ val digitsOnly = phoneNumber.substring(1) // Remove +
+ val countryCodeLength = when {
+ digitsOnly.length > 10 -> 2 // Likely 2-digit country code
+ digitsOnly[0] == '1' -> 1 // North America
+ else -> 2 // Most other countries
+ }
+
+ val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include +
+ // Keep last 3-4 digits visible, with longer numbers showing more
+ val lastDigitsCount = when {
+ phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits
+ phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits
+ else -> 2 // Short numbers show 2 digits
+ }
+ val lastDigits = phoneNumber.takeLast(lastDigitsCount)
+ val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount
+
+ return "$countryCode${"•".repeat(maskedLength)}$lastDigits"
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt
new file mode 100644
index 000000000..33bc874c6
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpEnrollmentHandler.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.auth.FirebaseUser
+import com.google.firebase.auth.MultiFactorAssertion
+import com.google.firebase.auth.TotpMultiFactorGenerator
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Handler for TOTP (Time-based One-Time Password) multi-factor authentication enrollment.
+ *
+ * This class manages the complete TOTP enrollment flow, including:
+ * - Generating TOTP secrets
+ * - Creating QR codes for authenticator apps
+ * - Verifying TOTP codes with clock drift tolerance
+ * - Finalizing enrollment with Firebase Authentication
+ *
+ * **Usage:**
+ * ```kotlin
+ * val handler = TotpEnrollmentHandler(auth, user)
+ *
+ * // Step 1: Generate a TOTP secret
+ * val totpSecret = handler.generateSecret()
+ *
+ * // Step 2: Display QR code to user
+ * val qrCodeUrl = totpSecret.generateQrCodeUrl(user.email, "My App")
+ *
+ * // Step 3: Verify the code entered by the user
+ * val verificationCode = "123456" // From user input
+ * handler.enrollWithVerificationCode(totpSecret, verificationCode, "My Authenticator")
+ * ```
+ *
+ * @property auth The [FirebaseAuth] instance
+ * @property user The [FirebaseUser] to enroll in TOTP MFA
+ *
+ * @since 10.0.0
+ */
+class TotpEnrollmentHandler(
+ private val auth: FirebaseAuth,
+ private val user: FirebaseUser
+) {
+ /**
+ * Generates a new TOTP secret for the current user.
+ *
+ * This method initiates the TOTP enrollment process by creating a new secret that
+ * can be shared with an authenticator app. The secret must be displayed to the user
+ * (either as text or a QR code) so they can add it to their authenticator app.
+ *
+ * **Important:** The user must re-authenticate before calling this method if their
+ * session is not recent. Use [FirebaseUser.reauthenticate] if needed.
+ *
+ * @return A [TotpSecret] containing the shared secret and configuration parameters
+ * @throws Exception if the user needs to re-authenticate or if secret generation fails
+ *
+ * @see TotpSecret.generateQrCodeUrl
+ * @see TotpSecret.openInOtpApp
+ */
+ suspend fun generateSecret(): TotpSecret {
+ // Get the multi-factor session
+ val multiFactorSession = user.multiFactor.session.await()
+
+ // Generate the TOTP secret
+ val firebaseTotpSecret = TotpMultiFactorGenerator.generateSecret(multiFactorSession).await()
+
+ return TotpSecret.from(firebaseTotpSecret)
+ }
+
+ /**
+ * Verifies a TOTP code and completes the enrollment process.
+ *
+ * This method creates a multi-factor assertion using the provided TOTP secret and
+ * verification code, then enrolls the user in TOTP MFA with Firebase Authentication.
+ *
+ * The verification includes clock drift tolerance as configured in your Firebase project,
+ * allowing codes from adjacent time windows to be accepted. This accommodates minor
+ * time synchronization differences between the server and the user's device.
+ *
+ * @param totpSecret The [TotpSecret] generated in the first step
+ * @param verificationCode The 6-digit code from the user's authenticator app
+ * @param displayName Optional friendly name for this MFA factor (e.g., "Google Authenticator")
+ * @throws Exception if the verification code is invalid or if enrollment fails
+ *
+ * @see generateSecret
+ */
+ suspend fun enrollWithVerificationCode(
+ totpSecret: TotpSecret,
+ verificationCode: String,
+ displayName: String? = null
+ ) {
+ // Create the multi-factor assertion for enrollment
+ val multiFactorAssertion: MultiFactorAssertion =
+ TotpMultiFactorGenerator.getAssertionForEnrollment(
+ totpSecret.getFirebaseTotpSecret(),
+ verificationCode
+ )
+
+ // Enroll the user with the TOTP factor
+ user.multiFactor.enroll(multiFactorAssertion, displayName).await()
+ }
+
+ /**
+ * Validates that a verification code has the correct format for TOTP.
+ *
+ * This method performs basic client-side validation to ensure the code:
+ * - Is not null or empty
+ * - Contains only digits
+ * - Has exactly 6 digits (the standard TOTP code length)
+ *
+ * **Note:** This does not verify the code against the TOTP secret. Use
+ * [enrollWithVerificationCode] to perform actual verification with Firebase.
+ *
+ * @param code The verification code to validate
+ * @return `true` if the code has a valid format, `false` otherwise
+ */
+ fun isValidCodeFormat(code: String): Boolean {
+ return code.isNotBlank() &&
+ code.length == 6 &&
+ code.all { it.isDigit() }
+ }
+
+ companion object {
+ /**
+ * The standard length for TOTP verification codes.
+ */
+ const val TOTP_CODE_LENGTH = 6
+
+ /**
+ * The standard time interval in seconds for TOTP codes.
+ */
+ const val TOTP_TIME_INTERVAL_SECONDS = 30
+
+ /**
+ * The Firebase factor ID for TOTP multi-factor authentication.
+ */
+ const val FACTOR_ID = TotpMultiFactorGenerator.FACTOR_ID
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt
new file mode 100644
index 000000000..7a4121ba8
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/mfa/TotpSecret.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.mfa
+
+import android.content.Intent
+import android.net.Uri
+import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret
+
+/**
+ * Wrapper class for Firebase TOTP secret that provides additional utility methods
+ * for enrollment and integration with authenticator apps.
+ *
+ * This class encapsulates the Firebase [FirebaseTotpSecret] and provides methods to:
+ * - Access the shared secret key
+ * - Generate QR code URLs for easy scanning
+ * - Open authenticator apps for automatic configuration
+ * - Generate hashing algorithm and code generation parameters
+ *
+ * @property firebaseTotpSecret The underlying Firebase TOTP secret
+ *
+ * @since 10.0.0
+ */
+class TotpSecret internal constructor(
+ private val firebaseTotpSecret: FirebaseTotpSecret
+) {
+ /**
+ * The shared secret key that should be entered into an authenticator app.
+ * This is a base32-encoded string that can be manually typed if QR scanning is not available.
+ */
+ val sharedSecretKey: String
+ get() = firebaseTotpSecret.sharedSecretKey
+
+ /**
+ * Generates a Google Authenticator-compatible URI that can be encoded as a QR code
+ * or used to automatically configure an authenticator app.
+ *
+ * The generated URI follows the format:
+ * `otpauth://totp/{accountName}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}`
+ *
+ * @param accountName The account identifier, typically the user's email address
+ * @param issuer The name of your application or service
+ * @return A URI string that can be converted to a QR code or used as a deep link
+ *
+ * @see openInOtpApp
+ */
+ fun generateQrCodeUrl(accountName: String, issuer: String): String {
+ return firebaseTotpSecret.generateQrCodeUrl(accountName, issuer)
+ }
+
+ /**
+ * Attempts to open the device's default authenticator app with the TOTP configuration.
+ *
+ * This method creates an Intent with the provided QR code URL and attempts to open
+ * an authenticator app (such as Google Authenticator) that can handle the
+ * `otpauth://` URI scheme. If successful, the app will be pre-configured with the
+ * TOTP secret without requiring the user to manually scan a QR code.
+ *
+ * **Note:** This method may fail silently if no compatible authenticator app is installed
+ * or if the app doesn't support automatic configuration via URI.
+ *
+ * @param qrCodeUrl The OTP auth URL generated by [generateQrCodeUrl]
+ *
+ * @see generateQrCodeUrl
+ */
+ fun openInOtpApp(qrCodeUrl: String) {
+ firebaseTotpSecret.openInOtpApp(qrCodeUrl)
+ }
+
+ /**
+ * Gets the underlying Firebase TOTP secret for use in enrollment operations.
+ *
+ * This method is primarily used internally by the enrollment handler to complete
+ * the TOTP enrollment with Firebase Authentication.
+ *
+ * @return The underlying [FirebaseTotpSecret] instance
+ */
+ internal fun getFirebaseTotpSecret(): FirebaseTotpSecret {
+ return firebaseTotpSecret
+ }
+
+ companion object {
+ /**
+ * Creates a [TotpSecret] instance from a Firebase TOTP secret.
+ *
+ * @param firebaseTotpSecret The Firebase TOTP secret to wrap
+ * @return A new [TotpSecret] instance
+ */
+ internal fun from(firebaseTotpSecret: FirebaseTotpSecret): TotpSecret {
+ return TotpSecret(firebaseTotpSecret)
+ }
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/package-info.java b/auth/src/main/java/com/firebase/ui/auth/package-info.java
index 5c1ac6f75..20360b5e4 100644
--- a/auth/src/main/java/com/firebase/ui/auth/package-info.java
+++ b/auth/src/main/java/com/firebase/ui/auth/package-info.java
@@ -13,7 +13,7 @@
*/
/**
- * The Firebase AuthUI library. See the {@link com.firebase.ui.auth.AuthUI} entry class for
+ * The Firebase AuthUI library. See the {@link com.firebase.ui.auth.FirebaseAuthUI} entry class for
* information on using the library to manage signed-in user state.
*/
package com.firebase.ui.auth;
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java
deleted file mode 100644
index baad5c26e..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/AppCompatBase.java
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui;
-
-import android.annotation.SuppressLint;
-import android.content.pm.ActivityInfo;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.View;
-import android.view.WindowInsetsController;
-import android.view.WindowManager;
-
-import com.firebase.ui.auth.R;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowCompat;
-import androidx.core.view.WindowInsetsCompat;
-import androidx.core.view.WindowInsetsControllerCompat;
-import androidx.core.graphics.Insets;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentTransaction;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public abstract class AppCompatBase extends HelperActivityBase {
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setTheme(R.style.FirebaseUI); // Provides default values
- setTheme(getFlowParams().themeId);
-
- // Enable edge-to-edge
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
-
- if (getFlowParams().lockOrientation) {
- lockOrientation();
- }
- }
-
- @Override
- public void onAttachedToWindow() {
- super.onAttachedToWindow();
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- // Add padding to the top for API 35+ when using edge-to-edge
- View rootView = findViewById(android.R.id.content);
- ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
- Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
- v.setPadding(v.getPaddingLeft(), systemBars.top, v.getPaddingRight(), v.getPaddingBottom());
- return insets;
- });
- }
- }
-
- protected void switchFragment(@NonNull Fragment fragment,
- int fragmentId,
- @NonNull String tag,
- boolean withTransition,
- boolean addToBackStack) {
- FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
- if (withTransition) {
- ft.setCustomAnimations(R.anim.fui_slide_in_right, R.anim.fui_slide_out_left);
- }
- ft.replace(fragmentId, fragment, tag);
- if (addToBackStack) {
- ft.addToBackStack(null).commit();
- } else {
- ft.disallowAddToBackStack().commit();
- }
- }
-
- protected void switchFragment(@NonNull Fragment fragment, int fragmentId, @NonNull String tag) {
- switchFragment(fragment, fragmentId, tag, false, false);
- }
-
- @SuppressLint("SourceLockedOrientationActivity")
- private void lockOrientation() {
- setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/FragmentBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/FragmentBase.java
deleted file mode 100644
index 305b55ac9..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/FragmentBase.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.os.Bundle;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.google.firebase.auth.FirebaseUser;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentActivity;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public abstract class FragmentBase extends Fragment implements ProgressView {
- private HelperActivityBase mActivity;
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- FragmentActivity activity = getActivity();
- if (!(activity instanceof HelperActivityBase)) {
- throw new IllegalStateException("Cannot use this fragment without the helper activity");
- }
- mActivity = (HelperActivityBase) activity;
- }
-
- public FlowParameters getFlowParams() {
- return mActivity.getFlowParams();
- }
-
- public void startSaveCredentials(
- FirebaseUser firebaseUser,
- IdpResponse response,
- @Nullable String password) {
- mActivity.startSaveCredentials(firebaseUser, response, password);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/HelperActivityBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/HelperActivityBase.java
deleted file mode 100644
index 96714101b..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/HelperActivityBase.java
+++ /dev/null
@@ -1,111 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.net.ConnectivityManager;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.credentials.CredentialSaveActivity;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.firebase.auth.FirebaseAuth;
-import com.google.firebase.auth.FirebaseUser;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.appcompat.app.AppCompatActivity;
-
-import static com.firebase.ui.auth.util.Preconditions.checkNotNull;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public abstract class HelperActivityBase extends AppCompatActivity implements ProgressView {
- private FlowParameters mParams;
-
- protected static Intent createBaseIntent(
- @NonNull Context context,
- @NonNull Class extends Activity> target,
- @NonNull FlowParameters flowParams) {
- Intent intent = new Intent(
- checkNotNull(context, "context cannot be null"),
- checkNotNull(target, "target activity cannot be null"))
- .putExtra(ExtraConstants.FLOW_PARAMS,
- checkNotNull(flowParams, "flowParams cannot be null"));
- intent.setExtrasClassLoader(AuthUI.class.getClassLoader());
- return intent;
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- // Forward the results of CredentialManager saving
- if (requestCode == RequestCodes.CRED_SAVE_FLOW
- || resultCode == ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT) {
- finish(resultCode, data);
- }
- }
-
- public FlowParameters getFlowParams() {
- if (mParams == null) {
- mParams = FlowParameters.fromIntent(getIntent());
- }
- return mParams;
- }
-
- public AuthUI getAuthUI() {
- return AuthUI.getInstance(getFlowParams().appName);
- }
-
- public FirebaseAuth getAuth() {
- return getAuthUI().getAuth();
- }
-
- public void finish(int resultCode, @Nullable Intent intent) {
- setResult(resultCode, intent);
- finish();
- }
-
- /**
- * Starts the CredentialManager save flow.
- *
- *
Instead of building a SmartLock {@link com.google.android.gms.auth.api.credentials.Credential},
- * we now extract the user's email (or phone number as a fallback) and pass it along with the
- * password and response.
- *
- * @param firebaseUser the currently signed-in user.
- * @param response the IdP response.
- * @param password the password used during sign-in (may be {@code null}).
- */
- public void startSaveCredentials(
- FirebaseUser firebaseUser,
- IdpResponse response,
- @Nullable String password) {
- // Extract email; if null, fallback to the phone number.
- String email = firebaseUser.getEmail();
- if (email == null) {
- email = firebaseUser.getPhoneNumber();
- }
- // Start the dedicated CredentialManager Activity.
- Intent intent = CredentialSaveActivity.createIntent(
- this, getFlowParams(), email, password, response);
- startActivityForResult(intent, RequestCodes.CRED_SAVE_FLOW);
- }
-
- /**
- * Check if there is an active or soon-to-be-active network connection.
- *
- * @return true if there is no network connection, false otherwise.
- */
- protected boolean isOffline() {
- ConnectivityManager manager = (ConnectivityManager) getApplicationContext()
- .getSystemService(Context.CONNECTIVITY_SERVICE);
-
- return !(manager != null
- && manager.getActiveNetworkInfo() != null
- && manager.getActiveNetworkInfo().isConnectedOrConnecting());
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleActivityBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleActivityBase.java
deleted file mode 100644
index ae0769a90..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleActivityBase.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.os.Handler;
-import android.view.ContextThemeWrapper;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-
-import com.firebase.ui.auth.R;
-import com.google.android.material.progressindicator.CircularProgressIndicator;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-/**
- * Base classes for activities that are just simple overlays.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class InvisibleActivityBase extends HelperActivityBase {
-
- // Minimum time that the spinner will stay on screen, once it is shown.
- private static final long MIN_SPINNER_MS = 750;
-
- private Handler mHandler = new Handler();
- private CircularProgressIndicator mProgressBar;
-
- // Last time that the progress bar was actually shown
- private long mLastShownTime = 0;
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_activity_invisible);
-
- // Create an indeterminate, circular progress bar in the app's theme
- mProgressBar = new CircularProgressIndicator(new ContextThemeWrapper(this, getFlowParams().themeId));
- mProgressBar.setIndeterminate(true);
- mProgressBar.setVisibility(View.GONE);
-
- // Set bar to float in the center
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.gravity = Gravity.CENTER;
-
- // Add to the container
- FrameLayout container = findViewById(R.id.invisible_frame);
- container.addView(mProgressBar, params);
- }
-
- @Override
- public void showProgress(int message) {
- if (mProgressBar.getVisibility() == View.VISIBLE) {
- mHandler.removeCallbacksAndMessages(null);
- return;
- }
-
- mLastShownTime = System.currentTimeMillis();
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- doAfterTimeout(() -> {
- mLastShownTime = 0;
- mProgressBar.setVisibility(View.GONE);
- });
- }
-
- @Override
- public void finish(int resultCode, @Nullable Intent intent) {
- setResult(resultCode, intent);
- doAfterTimeout(() -> finish());
- }
-
- /**
- * For certain actions (like finishing or hiding the progress dialog) we want to make sure
- * that we have shown the progress state for at least MIN_SPINNER_MS to prevent flickering.
- *
- * This method performs some action after the window has passed, or immediately if we have
- * already waited longer than that.
- */
- private void doAfterTimeout(Runnable runnable) {
- long currentTime = System.currentTimeMillis();
- long diff = currentTime - mLastShownTime;
-
- // 'diff' is how long it's been since we showed the spinner, so in the
- // case where diff is greater than our minimum spinner duration then our
- // remaining wait time is 0.
- long remaining = Math.max(MIN_SPINNER_MS - diff, 0);
-
- mHandler.postDelayed(runnable, remaining);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleFragmentBase.java b/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleFragmentBase.java
deleted file mode 100644
index d4e3cc2e2..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/InvisibleFragmentBase.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import android.os.Bundle;
-import android.os.Handler;
-import android.view.ContextThemeWrapper;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-
-import com.firebase.ui.auth.R;
-import com.google.android.material.progressindicator.CircularProgressIndicator;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class InvisibleFragmentBase extends FragmentBase {
-
- // Minimum time that the spinner will stay on screen, once it is shown.
- private static final long MIN_SPINNER_MS = 750;
- protected FrameLayout mFrameLayout;
- protected View mTopLevelView;
- private Handler mHandler = new Handler();
- private CircularProgressIndicator mProgressBar;
- // Last time that the progress bar was actually shown
- private long mLastShownTime = 0;
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-
- // Create an indeterminate, circular progress bar in the app's theme
- mProgressBar = new CircularProgressIndicator(new ContextThemeWrapper(getContext(),
- getFlowParams().themeId));
- mProgressBar.setIndeterminate(true);
- mProgressBar.setVisibility(View.GONE);
-
- // Set bar to float in the center
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.gravity = Gravity.CENTER;
-
- // Add to the container
- mFrameLayout = view.findViewById(R.id.invisible_frame);
- mFrameLayout.addView(mProgressBar, params);
- }
-
-
- @Override
- public void showProgress(int message) {
- if (mProgressBar.getVisibility() == View.VISIBLE) {
- mHandler.removeCallbacksAndMessages(null);
- return;
- }
-
- mLastShownTime = System.currentTimeMillis();
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- doAfterTimeout(() -> {
- mLastShownTime = 0;
- mProgressBar.setVisibility(View.GONE);
- mFrameLayout.setVisibility(View.GONE);
- });
- }
-
- /**
- * For certain actions (like finishing or hiding the progress dialog) we want to make sure
- * that we have shown the progress state for at least MIN_SPINNER_MS to prevent flickering.
- *
- * This method performs some action after the window has passed, or immediately if we have
- * already waited longer than that.
- */
- protected void doAfterTimeout(Runnable runnable) {
- long currentTime = System.currentTimeMillis();
- long diff = currentTime - mLastShownTime;
-
- // 'diff' is how long it's been since we showed the spinner, so in the
- // case where diff is greater than our minimum spinner duration then our
- // remaining wait time is 0.
- long remaining = Math.max(MIN_SPINNER_MS - diff, 0);
-
- mHandler.postDelayed(runnable, remaining);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/ProgressView.java b/auth/src/main/java/com/firebase/ui/auth/ui/ProgressView.java
deleted file mode 100644
index f33daf1de..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/ProgressView.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.firebase.ui.auth.ui;
-
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringRes;
-
-/**
- * View (Activity or Fragment, normally) that can respond to progress events.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public interface ProgressView {
-
- void showProgress(@StringRes int message);
-
- void hideProgress();
-
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt
new file mode 100644
index 000000000..29a406821
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthProviderButton.kt
@@ -0,0 +1,319 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.auth_provider.Provider
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+
+/**
+ * A customizable button for an authentication provider.
+ *
+ * This button displays the icon and name of an authentication provider (e.g., Google, Facebook).
+ * It is designed to be used within a list of sign-in options. The button's appearance can be
+ * customized using the [style] parameter, and its text is localized via the [stringProvider].
+ *
+ * **Example usage:**
+ * ```kotlin
+ * AuthProviderButton(
+ * provider = AuthProvider.Facebook(),
+ * onClick = { /* Handle Facebook sign-in */ },
+ * stringProvider = DefaultAuthUIStringProvider(LocalContext.current)
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the button
+ * @param provider The provider to represent.
+ * @param onClick A callback when the button is clicked
+ * @param enabled If the button is enabled. Defaults to true.
+ * @param style Optional custom styling for the button.
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun AuthProviderButton(
+ modifier: Modifier = Modifier,
+ provider: AuthProvider,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ style: AuthUITheme.ProviderStyle? = null,
+ stringProvider: AuthUIStringProvider,
+) {
+ val context = LocalContext.current
+ val providerStyle = resolveProviderStyle(provider, style)
+ val providerLabel = resolveProviderLabel(provider, stringProvider, context)
+
+ Button(
+ modifier = modifier,
+ contentPadding = PaddingValues(horizontal = 12.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = providerStyle.backgroundColor,
+ contentColor = providerStyle.contentColor,
+ ),
+ shape = providerStyle.shape,
+ elevation = ButtonDefaults.buttonElevation(
+ defaultElevation = providerStyle.elevation
+ ),
+ onClick = onClick,
+ enabled = enabled,
+ ) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ val providerIcon = providerStyle.icon
+ if (providerIcon != null) {
+ val iconTint = providerStyle.iconTint
+ if (iconTint != null) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp),
+ painter = providerIcon.painter,
+ contentDescription = providerLabel,
+ tint = iconTint
+ )
+ } else {
+ Image(
+ modifier = Modifier
+ .size(24.dp),
+ painter = providerIcon.painter,
+ contentDescription = providerLabel
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+ Text(
+ text = providerLabel,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+internal fun resolveProviderStyle(
+ provider: AuthProvider,
+ style: AuthUITheme.ProviderStyle?,
+): AuthUITheme.ProviderStyle {
+ if (style != null) return style
+
+ val defaultStyle =
+ AuthUITheme.Default.providerStyles[provider.providerId] ?: AuthUITheme.ProviderStyle.Empty
+
+ return if (provider is AuthProvider.GenericOAuth) {
+ AuthUITheme.ProviderStyle(
+ icon = provider.buttonIcon ?: defaultStyle.icon,
+ backgroundColor = provider.buttonColor ?: defaultStyle.backgroundColor,
+ contentColor = provider.contentColor ?: defaultStyle.contentColor,
+ )
+ } else {
+ defaultStyle
+ }
+}
+
+internal fun resolveProviderLabel(
+ provider: AuthProvider,
+ stringProvider: AuthUIStringProvider,
+ context: android.content.Context
+): String = when (provider) {
+ is AuthProvider.GenericOAuth -> provider.buttonLabel
+ is AuthProvider.Apple -> {
+ // Use Apple-specific locale if provided, otherwise use default stringProvider
+ if (provider.locale != null) {
+ val appleLocale = java.util.Locale.forLanguageTag(provider.locale)
+ val appleStringProvider = DefaultAuthUIStringProvider(context, appleLocale)
+ appleStringProvider.signInWithApple
+ } else {
+ stringProvider.signInWithApple
+ }
+ }
+ else -> when (Provider.fromId(provider.providerId)) {
+ Provider.GOOGLE -> stringProvider.signInWithGoogle
+ Provider.FACEBOOK -> stringProvider.signInWithFacebook
+ Provider.TWITTER -> stringProvider.signInWithTwitter
+ Provider.GITHUB -> stringProvider.signInWithGithub
+ Provider.EMAIL -> stringProvider.signInWithEmail
+ Provider.PHONE -> stringProvider.signInWithPhone
+ Provider.ANONYMOUS -> stringProvider.signInAnonymously
+ Provider.MICROSOFT -> stringProvider.signInWithMicrosoft
+ Provider.YAHOO -> stringProvider.signInWithYahoo
+ Provider.APPLE -> stringProvider.signInWithApple
+ null -> "Unknown Provider"
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewAuthProviderButton() {
+ val context = LocalContext.current
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AuthProviderButton(
+ provider = AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Google(
+ scopes = emptyList(),
+ serverClientId = null
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Facebook(),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Twitter(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Github(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Microsoft(
+ tenant = null,
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Yahoo(
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Apple(
+ locale = null,
+ customParameters = emptyMap()
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.Anonymous,
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "google.com",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Generic Provider",
+ buttonIcon = AuthUIAsset.Vector(Icons.Default.Star),
+ buttonColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "google.com",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Custom Style",
+ buttonIcon = AuthUIAsset.Vector(Icons.Default.Star),
+ buttonColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ onClick = {},
+ style = AuthUITheme.ProviderStyle(
+ icon = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]?.icon,
+ backgroundColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.backgroundColor,
+ contentColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.contentColor,
+ iconTint = Color.Red,
+ shape = RoundedCornerShape(24.dp),
+ elevation = 6.dp
+ ),
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ AuthProviderButton(
+ provider = AuthProvider.GenericOAuth(
+ providerName = "Generic Provider",
+ providerId = "unknown_provider",
+ scopes = emptyList(),
+ customParameters = emptyMap(),
+ buttonLabel = "Unsupported Provider",
+ buttonIcon = null,
+ buttonColor = null,
+ contentColor = null,
+ ),
+ onClick = {},
+ stringProvider = DefaultAuthUIStringProvider(context)
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
new file mode 100644
index 000000000..253a6e260
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/AuthTextField.kt
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.PasswordRule
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.configuration.validators.EmailValidator
+import com.firebase.ui.auth.configuration.validators.FieldValidator
+import com.firebase.ui.auth.configuration.validators.PasswordValidator
+
+/**
+ * A customizable input field with built-in validation display.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * val emailTextValue = remember { mutableStateOf("") }
+ *
+ * val emailValidator = remember {
+ * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context))
+ * }
+ *
+ * AuthTextField(
+ * value = emailTextValue,
+ * onValueChange = { emailTextValue.value = it },
+ * label = {
+ * Text("Email")
+ * },
+ * validator = emailValidator
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the field.
+ * @param value The current value of the text field.
+ * @param onValueChange A callback when the value changes.
+ * @param label The label for the text field.
+ * @param enabled If the field is enabled.
+ * @param isError Manually set the error state.
+ * @param errorMessage A custom error message to display.
+ * @param validator A validator to automatically handle error state and messages.
+ * @param keyboardOptions Keyboard options for the field.
+ * @param keyboardActions Keyboard actions for the field.
+ * @param visualTransformation Visual transformation for the input (e.g., password).
+ * @param leadingIcon An optional icon to display at the start of the field.
+ * @param trailingIcon An optional icon to display at the start of the field.
+ */
+@Composable
+fun AuthTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: @Composable (() -> Unit)? = null,
+ isSecureTextField: Boolean = false,
+ enabled: Boolean = true,
+ isError: Boolean? = null,
+ errorMessage: String? = null,
+ validator: FieldValidator? = null,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+) {
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ // Automatically set the correct keyboard type based on validator or field type
+ val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) {
+ when {
+ keyboardOptions != KeyboardOptions.Default -> keyboardOptions
+ validator is EmailValidator -> KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next
+ )
+ isSecureTextField -> KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ )
+ else -> keyboardOptions
+ }
+ }
+
+ TextField(
+ modifier = modifier
+ .fillMaxWidth(),
+ value = value,
+ onValueChange = { newValue ->
+ onValueChange(newValue)
+ validator?.validate(newValue)
+ },
+ label = label,
+ singleLine = true,
+ enabled = enabled,
+ isError = isError ?: validator?.hasError ?: false,
+ supportingText = {
+ if (validator?.hasError ?: false) {
+ Text(text = errorMessage ?: validator.errorMessage)
+ }
+ },
+ keyboardOptions = resolvedKeyboardOptions,
+ keyboardActions = keyboardActions,
+ visualTransformation = if (isSecureTextField && !passwordVisible)
+ PasswordVisualTransformation() else visualTransformation,
+ leadingIcon = leadingIcon ?: when {
+ validator is EmailValidator -> {
+ {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = "Email Input Icon"
+ )
+ }
+ }
+
+ isSecureTextField -> {
+ {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Password Input Icon"
+ )
+ }
+ }
+
+ else -> null
+ },
+ trailingIcon = trailingIcon ?: {
+ if (isSecureTextField) {
+ IconButton(
+ onClick = {
+ passwordVisible = !passwordVisible
+ }
+ ) {
+ Icon(
+ imageVector = if (passwordVisible)
+ Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
+ contentDescription = if (passwordVisible) "Hide password" else "Show password"
+ )
+ }
+ }
+ },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+internal fun PreviewAuthTextField() {
+ val context = LocalContext.current
+ val nameTextValue = remember { mutableStateOf("") }
+ val emailTextValue = remember { mutableStateOf("") }
+ val passwordTextValue = remember { mutableStateOf("") }
+ val emailValidator = remember {
+ EmailValidator(stringProvider = DefaultAuthUIStringProvider(context))
+ }
+ val passwordValidator = remember {
+ PasswordValidator(
+ stringProvider = DefaultAuthUIStringProvider(context),
+ rules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireUppercase,
+ PasswordRule.RequireLowercase,
+ )
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ AuthTextField(
+ value = nameTextValue.value,
+ label = {
+ Text("Name")
+ },
+ onValueChange = { text ->
+ nameTextValue.value = text
+ },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = emailTextValue.value,
+ validator = emailValidator,
+ label = {
+ Text("Email")
+ },
+ onValueChange = { text ->
+ emailTextValue.value = text
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = passwordTextValue.value,
+ validator = passwordValidator,
+ isSecureTextField = true,
+ label = {
+ Text("Password")
+ },
+ onValueChange = { text ->
+ passwordTextValue.value = text
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = ""
+ )
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt
new file mode 100644
index 000000000..425aa32bc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/CountrySelector.kt
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.data.ALL_COUNTRIES
+import com.firebase.ui.auth.data.CountryData
+import com.firebase.ui.auth.util.CountryUtils
+import kotlinx.coroutines.launch
+
+/**
+ * A country selector component that displays the selected country's flag and dial code with a dropdown icon.
+ * Designed to be used as a leadingIcon in a TextField.
+ *
+ * @param selectedCountry The currently selected country.
+ * @param onCountrySelected Callback when a country is selected.
+ * @param enabled Whether the selector is enabled.
+ * @param allowedCountries Optional set of allowed country codes to filter the list.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CountrySelector(
+ selectedCountry: CountryData,
+ onCountrySelected: (CountryData) -> Unit,
+ enabled: Boolean = true,
+ allowedCountries: Set? = null,
+) {
+ val context = LocalContext.current
+ val stringProvider = LocalAuthUIStringProvider.current
+ val sheetState = rememberModalBottomSheetState()
+ val scope = rememberCoroutineScope()
+ var showBottomSheet by remember { mutableStateOf(false) }
+ var searchQuery by remember { mutableStateOf("") }
+
+ val countriesList = remember(allowedCountries) {
+ if (allowedCountries != null) {
+ CountryUtils.filterByAllowedCountries(allowedCountries)
+ } else {
+ ALL_COUNTRIES
+ }
+ }
+
+ val filteredCountries = remember(searchQuery, countriesList) {
+ if (searchQuery.isEmpty()) {
+ countriesList
+ } else {
+ CountryUtils.search(searchQuery).filter { country ->
+ countriesList.any { it.countryCode == country.countryCode }
+ }
+ }
+ }
+
+ // Clickable row showing flag, dial code and dropdown icon
+ Row(
+ modifier = Modifier
+ .fillMaxHeight()
+ .clickable(enabled = enabled) {
+ showBottomSheet = true
+ }
+ .padding(start = 8.dp)
+ .semantics {
+ role = Role.DropdownList
+ contentDescription = "Country selector"
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = selectedCountry.flagEmoji,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = selectedCountry.dialCode,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Select country",
+ modifier = Modifier.padding(PaddingValues.Zero)
+ )
+ }
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ showBottomSheet = false
+ searchQuery = ""
+ },
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp)
+ ) {
+ Text(
+ text = stringProvider.countrySelectorModalTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it },
+ label = { Text(stringProvider.searchCountriesHint) },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(500.dp)
+ .testTag("CountrySelector LazyColumn")
+ ) {
+ items(filteredCountries) { country ->
+ Button(
+ onClick = {
+ onCountrySelected(country)
+ scope.launch {
+ sheetState.hide()
+ showBottomSheet = false
+ searchQuery = ""
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ containerColor = Color.Transparent
+ ),
+ contentPadding = PaddingValues.Zero
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = country.flagEmoji,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = country.name,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ Text(
+ text = country.dialCode,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..96c2e7978
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ErrorRecoveryDialog.kt
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+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.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+
+/**
+ * A composable dialog for displaying authentication errors with recovery options.
+ *
+ * This dialog provides friendly error messages and actionable recovery suggestions
+ * based on the specific [AuthException] type. It integrates with [AuthUIStringProvider]
+ * for localization support.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * var showError by remember { mutableStateOf(null) }
+ *
+ * if (showError != null) {
+ * ErrorRecoveryDialog(
+ * error = showError!!,
+ * stringProvider = stringProvider,
+ * onRetry = {
+ * showError = null
+ * // Retry authentication operation
+ * },
+ * onDismiss = {
+ * showError = null
+ * }
+ * )
+ * }
+ * ```
+ *
+ * @param error The [AuthException] to display recovery information for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @param onRetry Callback invoked when the user taps the retry action
+ * @param onDismiss Callback invoked when the user dismisses the dialog
+ * @param modifier Optional [Modifier] for the dialog
+ * @param onRecover Optional callback for custom recovery actions based on the exception type
+ * @param properties Optional [DialogProperties] for dialog configuration
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun ErrorRecoveryDialog(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider,
+ onRetry: (AuthException) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ onRecover: ((AuthException) -> Unit)? = null,
+ properties: DialogProperties = DialogProperties()
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = stringProvider.errorDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = getRecoveryMessage(error, stringProvider),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ if (isRecoverable(error)) {
+ TextButton(
+ onClick = {
+ onRecover?.invoke(error) ?: onRetry(error)
+ }
+ ) {
+ Text(
+ text = getRecoveryActionText(error, stringProvider),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(
+ text = stringProvider.dismissAction,
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ },
+ modifier = modifier,
+ properties = properties
+ )
+}
+
+/**
+ * Gets the appropriate recovery message for the given [AuthException].
+ *
+ * @param error The [AuthException] to get the message for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @return The localized recovery message
+ */
+private fun getRecoveryMessage(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider
+): String {
+ return when (error) {
+ is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage
+ is AuthException.InvalidCredentialsException -> stringProvider.invalidCredentialsRecoveryMessage
+ is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage
+ is AuthException.WeakPasswordException -> {
+ // Include specific reason if available
+ val baseMessage = stringProvider.weakPasswordRecoveryMessage
+ error.reason?.let { reason ->
+ "$baseMessage\n\nReason: $reason"
+ } ?: baseMessage
+ }
+
+ is AuthException.EmailAlreadyInUseException -> {
+ // Include email if available
+ val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage
+ error.email?.let { email ->
+ "$baseMessage ($email)"
+ } ?: baseMessage
+ }
+
+ is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage
+ is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage
+ is AuthException.AccountLinkingRequiredException -> {
+ // Use the custom message which includes email and provider details
+ error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage
+ }
+ is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage
+ is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage
+ is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage
+ is AuthException.EmailLinkDifferentAnonymousUserException ->
+ stringProvider.emailLinkDifferentAnonymousUserMessage
+ is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage
+ is AuthException.EmailLinkCrossDeviceLinkingException -> {
+ val providerName = error.providerName ?: stringProvider.emailProvider
+ stringProvider.emailLinkCrossDeviceLinkingMessage(providerName)
+ }
+ is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage
+ is AuthException.UnknownException -> {
+ // Use custom message if available (e.g., for configuration errors)
+ error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage
+ }
+ else -> stringProvider.unknownErrorRecoveryMessage
+ }
+}
+
+/**
+ * Gets the appropriate recovery action text for the given [AuthException].
+ *
+ * @param error The [AuthException] to get the action text for
+ * @param stringProvider The [AuthUIStringProvider] for localized strings
+ * @return The localized action text
+ */
+private fun getRecoveryActionText(
+ error: AuthException,
+ stringProvider: AuthUIStringProvider
+): String {
+ return when (error) {
+ 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.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA
+ is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText
+ is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText
+ is AuthException.EmailLinkWrongDeviceException -> stringProvider.continueText
+ is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.dismissAction
+ is AuthException.UserNotFoundException -> stringProvider.signupPageTitle // Navigate to sign-up when user not found
+ is AuthException.NetworkException,
+ is AuthException.InvalidCredentialsException,
+ is AuthException.WeakPasswordException,
+ is AuthException.TooManyRequestsException,
+ is AuthException.UnknownException -> stringProvider.retryAction
+
+ else -> stringProvider.retryAction
+ }
+}
+
+/**
+ * Determines if the given [AuthException] is recoverable through user action.
+ *
+ * @param error The [AuthException] to check
+ * @return `true` if the error is recoverable, `false` otherwise
+ */
+private fun isRecoverable(error: AuthException): Boolean {
+ return when (error) {
+ is AuthException.NetworkException -> true
+ is AuthException.InvalidCredentialsException -> true
+ is AuthException.UserNotFoundException -> true
+ is AuthException.WeakPasswordException -> true
+ is AuthException.EmailAlreadyInUseException -> true
+ is AuthException.TooManyRequestsException -> false // User must wait
+ is AuthException.MfaRequiredException -> true
+ is AuthException.AccountLinkingRequiredException -> true
+ is AuthException.AuthCancelledException -> true
+ is AuthException.EmailLinkPromptForEmailException -> true
+ is AuthException.EmailLinkCrossDeviceLinkingException -> true
+ is AuthException.EmailLinkWrongDeviceException -> true
+ is AuthException.EmailLinkDifferentAnonymousUserException -> false
+ is AuthException.UnknownException -> true
+ else -> true
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt
new file mode 100644
index 000000000..754aa5cc7
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/QrCodeImage.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.WriterException
+import com.google.zxing.qrcode.QRCodeWriter
+
+/**
+ * Renders a QR code from the provided content string.
+ *
+ * This component is typically used to display TOTP enrollment URIs. The QR code is generated on the
+ * fly and memoized for the given [content].
+ *
+ * @param content The string content to encode into the QR code (for example the TOTP URI).
+ * @param modifier Optional [Modifier] applied to the QR container.
+ * @param size The size of the QR code square in density-independent pixels.
+ * @param foregroundColor Color used to render the QR pixels (defaults to black).
+ * @param backgroundColor Background color for the QR code (defaults to white).
+ */
+@Composable
+fun QrCodeImage(
+ content: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 250.dp,
+ foregroundColor: Color = Color.Black,
+ backgroundColor: Color = Color.White
+) {
+ val bitmap = remember(content, size, foregroundColor, backgroundColor) {
+ generateQrCodeBitmap(
+ content = content,
+ sizePx = (size.value * 2).toInt(), // Render at 2x for better scaling quality.
+ foregroundColor = foregroundColor,
+ backgroundColor = backgroundColor
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ bitmap?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "QR code for authenticator app setup",
+ modifier = Modifier.size(size)
+ )
+ }
+ }
+}
+
+private fun generateQrCodeBitmap(
+ content: String,
+ sizePx: Int,
+ foregroundColor: Color,
+ backgroundColor: Color
+): Bitmap? {
+ return try {
+ val qrCodeWriter = QRCodeWriter()
+ val hints = mapOf(
+ EncodeHintType.MARGIN to 1 // Small margin keeps QR code compact while remaining scannable.
+ )
+
+ val bitMatrix = qrCodeWriter.encode(
+ content,
+ BarcodeFormat.QR_CODE,
+ sizePx,
+ sizePx,
+ hints
+ )
+
+ val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
+
+ val foregroundArgb = android.graphics.Color.argb(
+ (foregroundColor.alpha * 255).toInt(),
+ (foregroundColor.red * 255).toInt(),
+ (foregroundColor.green * 255).toInt(),
+ (foregroundColor.blue * 255).toInt()
+ )
+
+ val backgroundArgb = android.graphics.Color.argb(
+ (backgroundColor.alpha * 255).toInt(),
+ (backgroundColor.red * 255).toInt(),
+ (backgroundColor.green * 255).toInt(),
+ (backgroundColor.blue * 255).toInt()
+ )
+
+ for (x in 0 until sizePx) {
+ for (y in 0 until sizePx) {
+ bitmap.setPixel(
+ x,
+ y,
+ if (bitMatrix[x, y]) foregroundArgb else backgroundArgb
+ )
+ }
+ }
+
+ bitmap
+ } catch (e: WriterException) {
+ null
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
new file mode 100644
index 000000000..08c9ed89c
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/ReauthenticationDialog.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.google.firebase.auth.EmailAuthProvider
+import com.google.firebase.auth.FirebaseUser
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Dialog presented when Firebase requires the current user to re-authenticate before performing
+ * a sensitive operation (for example, MFA enrollment).
+ */
+@Composable
+fun ReauthenticationDialog(
+ user: FirebaseUser,
+ onDismiss: () -> Unit,
+ onSuccess: () -> Unit,
+ onError: (Exception) -> Unit
+) {
+ var password by remember { mutableStateOf("") }
+ var isLoading by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+ val focusRequester = remember { FocusRequester() }
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ AlertDialog(
+ onDismissRequest = { if (!isLoading) onDismiss() },
+ title = {
+ Text(
+ text = stringProvider.reauthDialogTitle,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = stringProvider.reauthDialogMessage,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ user.email?.let { email ->
+ Text(
+ text = stringProvider.reauthAccountLabel(email),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ OutlinedTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = null
+ },
+ label = { Text(stringProvider.passwordHint) },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (password.isNotBlank() && !isLoading) {
+ coroutineScope.launch {
+ reauthenticate(
+ user = user,
+ password = password,
+ onLoading = { isLoading = it },
+ onSuccess = onSuccess,
+ onError = { error ->
+ errorMessage = error.toUserMessage(stringProvider)
+ onError(error)
+ }
+ )
+ }
+ }
+ }
+ ),
+ enabled = !isLoading,
+ isError = errorMessage != null,
+ supportingText = errorMessage?.let { message -> { Text(message) } },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 8.dp)
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ reauthenticate(
+ user = user,
+ password = password,
+ onLoading = { isLoading = it },
+ onSuccess = onSuccess,
+ onError = { error ->
+ errorMessage = error.toUserMessage(stringProvider)
+ onError(error)
+ }
+ )
+ }
+ },
+ enabled = password.isNotBlank() && !isLoading
+ ) {
+ Text(stringProvider.verifyAction)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = onDismiss,
+ enabled = !isLoading
+ ) {
+ Text(stringProvider.dismissAction)
+ }
+ }
+ )
+}
+
+private suspend fun reauthenticate(
+ user: FirebaseUser,
+ password: String,
+ onLoading: (Boolean) -> Unit,
+ onSuccess: () -> Unit,
+ onError: (Exception) -> Unit
+) {
+ try {
+ onLoading(true)
+ val email = requireNotNull(user.email) {
+ "Email must be available to re-authenticate with password."
+ }
+
+ val credential = EmailAuthProvider.getCredential(email, password)
+ user.reauthenticate(credential).await()
+ onSuccess()
+ } catch (e: Exception) {
+ onError(e)
+ } finally {
+ onLoading(false)
+ }
+}
+
+private fun Exception.toUserMessage(stringProvider: AuthUIStringProvider): String = when {
+ message?.contains("password", ignoreCase = true) == true ->
+ stringProvider.incorrectPasswordError
+ message?.contains("network", ignoreCase = true) == true ->
+ stringProvider.noInternet
+ else -> stringProvider.reauthGenericError
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt
new file mode 100644
index 000000000..601452006
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TermsAndPrivacyForm.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TermsAndPrivacyForm(
+ modifier: Modifier = Modifier,
+ tosUrl: String?,
+ ppUrl: String?
+) {
+ val uriHandler = LocalUriHandler.current
+ Row(
+ modifier = modifier,
+ ) {
+ TextButton(
+ onClick = {
+ tosUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = "Terms of Service",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.width(24.dp))
+ TextButton(
+ onClick = {
+ ppUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = "Privacy Policy",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..626c56c04
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/TopLevelDialogController.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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
+
+/**
+ * CompositionLocal for accessing the top-level dialog controller from any composable.
+ */
+val LocalTopLevelDialogController = compositionLocalOf {
+ null
+}
+
+/**
+ * A top-level dialog controller that allows any child composable to show error recovery dialogs.
+ *
+ * It provides a single point of control for showing dialogs from anywhere in the composition tree,
+ * preventing duplicate dialogs when multiple screens observe the same error state.
+ *
+ * **Usage:**
+ * ```kotlin
+ * // At the root of your auth flow (FirebaseAuthScreen):
+ * val dialogController = rememberTopLevelDialogController(stringProvider)
+ *
+ * CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) {
+ * // Your auth screens...
+ *
+ * // Show dialog at root level (only one instance)
+ * dialogController.CurrentDialog()
+ * }
+ *
+ * // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.):
+ * val dialogController = LocalTopLevelDialogController.current
+ *
+ * LaunchedEffect(error) {
+ * error?.let { exception ->
+ * dialogController?.showErrorDialog(
+ * exception = exception,
+ * onRetry = { ... },
+ * onRecover = { ... },
+ * onDismiss = { ... }
+ * )
+ * }
+ * }
+ * ```
+ *
+ * @since 10.0.0
+ */
+class TopLevelDialogController(
+ private val stringProvider: AuthUIStringProvider,
+ private val authState: AuthState
+) {
+ private var dialogState by mutableStateOf(null)
+ private val shownErrorStates = mutableSetOf()
+
+ /**
+ * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
+ * Automatically prevents duplicate dialogs for the same AuthState.Error instance.
+ *
+ * @param exception The auth exception to display
+ * @param onRetry Callback when user clicks retry button
+ * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
+ * @param onDismiss Callback when dialog is dismissed
+ */
+ fun showErrorDialog(
+ exception: AuthException,
+ onRetry: (AuthException) -> Unit = {},
+ onRecover: (AuthException) -> Unit = {},
+ onDismiss: () -> Unit = {}
+ ) {
+ // Get current error state
+ val currentErrorState = authState as? AuthState.Error
+
+ // If this exact error state has already been shown, skip
+ if (currentErrorState != null && currentErrorState in shownErrorStates) {
+ return
+ }
+
+ // Mark this error state as shown
+ currentErrorState?.let { shownErrorStates.add(it) }
+
+ dialogState = DialogState.ErrorDialog(
+ exception = exception,
+ onRetry = onRetry,
+ onRecover = onRecover,
+ onDismiss = {
+ dialogState = null
+ onDismiss()
+ }
+ )
+ }
+
+ /**
+ * Dismisses the currently shown dialog.
+ */
+ fun dismissDialog() {
+ dialogState = null
+ }
+
+ /**
+ * 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.
+ */
+ @Composable
+ fun CurrentDialog() {
+ val state = dialogState
+ when (state) {
+ is DialogState.ErrorDialog -> {
+ ErrorRecoveryDialog(
+ error = state.exception,
+ stringProvider = stringProvider,
+ onRetry = { exception ->
+ state.onRetry(exception)
+ state.onDismiss()
+ },
+ onRecover = { exception ->
+ state.onRecover(exception)
+ state.onDismiss()
+ },
+ onDismiss = state.onDismiss
+ )
+ }
+ null -> {
+ // No dialog to show
+ }
+ }
+ }
+
+ private sealed class DialogState {
+ data class ErrorDialog(
+ val exception: AuthException,
+ val onRetry: (AuthException) -> Unit,
+ val onRecover: (AuthException) -> Unit,
+ val onDismiss: () -> Unit
+ ) : DialogState()
+ }
+}
+
+/**
+ * Creates and remembers a [TopLevelDialogController].
+ */
+@Composable
+fun rememberTopLevelDialogController(
+ stringProvider: AuthUIStringProvider,
+ authState: AuthState
+): TopLevelDialogController {
+ return remember(stringProvider, authState) {
+ TopLevelDialogController(stringProvider, authState)
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
new file mode 100644
index 000000000..ab79e8954
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/components/VerificationCodeInputField.kt
@@ -0,0 +1,396 @@
+package com.firebase.ui.auth.ui.components
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.draw.clip
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.core.text.isDigitsOnly
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.configuration.validators.FieldValidator
+
+@Composable
+fun VerificationCodeInputField(
+ modifier: Modifier = Modifier,
+ codeLength: Int = 6,
+ validator: FieldValidator? = null,
+ isError: Boolean = false,
+ errorMessage: String? = null,
+ onCodeComplete: (String) -> Unit = {},
+ onCodeChange: (String) -> Unit = {},
+) {
+ val code = remember { mutableStateOf(List(codeLength) { null }) }
+ val focusedIndex = remember { mutableStateOf(null) }
+ val focusRequesters = remember { (1..codeLength).map { FocusRequester() } }
+ val keyboardManager = LocalSoftwareKeyboardController.current
+
+ // Derive validation state
+ val currentCodeString = remember { mutableStateOf("") }
+ val validationError = remember { mutableStateOf(null) }
+
+ // Auto-focus first field on initial composition
+ LaunchedEffect(Unit) {
+ focusRequesters.firstOrNull()?.requestFocus()
+ }
+
+ // Handle focus changes
+ LaunchedEffect(focusedIndex.value) {
+ focusedIndex.value?.let { index ->
+ focusRequesters.getOrNull(index)?.requestFocus()
+ }
+ }
+
+ // Handle code completion and validation
+ LaunchedEffect(code.value) {
+ val codeString = code.value.mapNotNull { it }.joinToString("")
+ currentCodeString.value = codeString
+ onCodeChange(codeString)
+
+ // Run validation if validator is provided
+ validator?.let {
+ val isValid = it.validate(codeString)
+ validationError.value = if (!isValid && codeString.length == codeLength) {
+ it.errorMessage
+ } else {
+ null
+ }
+ }
+
+ val allNumbersEntered = code.value.none { it == null }
+ if (allNumbersEntered) {
+ keyboardManager?.hide()
+ onCodeComplete(codeString)
+ }
+ }
+
+ // Determine error state: use validator if provided, otherwise use explicit isError
+ val showError = if (validator != null) {
+ validationError.value != null
+ } else {
+ isError
+ }
+
+ val displayErrorMessage = if (validator != null) {
+ validationError.value
+ } else {
+ errorMessage
+ }
+
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
+ ) {
+ code.value.forEachIndexed { index, number ->
+ SingleDigitField(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(1f),
+ number = number,
+ isError = showError,
+ focusRequester = focusRequesters[index],
+ onFocusChanged = { isFocused ->
+ if (isFocused) {
+ focusedIndex.value = index
+ }
+ },
+ onNumberChanged = { value ->
+ val oldValue = code.value[index]
+ val newCode = code.value.toMutableList()
+ newCode[index] = value
+ code.value = newCode
+
+ // Move focus to next field if number was entered (and field was previously empty)
+ if (value != null && oldValue == null) {
+ focusedIndex.value = getNextFocusedIndex(newCode, index)
+ }
+ },
+ onKeyboardBack = {
+ val previousIndex = getPreviousFocusedIndex(index)
+ if (previousIndex != null) {
+ val newCode = code.value.toMutableList()
+ newCode[previousIndex] = null
+ code.value = newCode
+ focusedIndex.value = previousIndex
+ }
+ },
+ onNumberEntered = {
+ focusRequesters[index].freeFocus()
+ }
+ )
+ }
+ }
+
+ if (showError && displayErrorMessage != null) {
+ Text(
+ modifier = Modifier.padding(top = 8.dp),
+ text = displayErrorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SingleDigitField(
+ modifier: Modifier = Modifier,
+ number: Int?,
+ isError: Boolean = false,
+ focusRequester: FocusRequester,
+ onFocusChanged: (Boolean) -> Unit,
+ onNumberChanged: (Int?) -> Unit,
+ onKeyboardBack: () -> Unit,
+ onNumberEntered: () -> Unit,
+) {
+ val text = remember { mutableStateOf(TextFieldValue()) }
+ val isFocused = remember { mutableStateOf(false) }
+
+ // Update text field value when number changes externally
+ LaunchedEffect(number) {
+ text.value = TextFieldValue(
+ text = number?.toString().orEmpty(),
+ selection = TextRange(
+ index = if (number != null) 1 else 0
+ )
+ )
+ }
+
+ val borderColor = if (isError) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+
+// val backgroundColor = if (isError) {
+// MaterialTheme.colorScheme.errorContainer
+// } else {
+// MaterialTheme.colorScheme.primaryContainer
+// }
+
+ val textColor = if (isError) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+
+ val targetBorderWidth = if (isError || isFocused.value || number != null) 2.dp else 1.dp
+ val animatedBorderWidth by animateDpAsState(
+ targetValue = targetBorderWidth,
+ animationSpec = tween(durationMillis = 150),
+ label = "borderWidth"
+ )
+
+ val shape = RoundedCornerShape(8.dp)
+
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .border(
+ width = animatedBorderWidth,
+ shape = shape,
+ color = borderColor,
+ ),
+ //.background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ BasicTextField(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize()
+ .semantics {
+ contentDescription = "Verification code digit"
+ }
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ isFocused.value = it.isFocused
+ onFocusChanged(it.isFocused)
+ }
+ .onPreviewKeyEvent { event ->
+ val isDelete = event.key == Key.Backspace || event.key == Key.Delete
+ val isInitialDown = event.type == KeyEventType.KeyDown &&
+ event.nativeKeyEvent.repeatCount == 0
+
+ if (isDelete && isInitialDown && number == null) {
+ onKeyboardBack()
+ return@onPreviewKeyEvent true
+ }
+ false
+ },
+ value = text.value,
+ onValueChange = { value ->
+ val newNumber = value.text
+ if (newNumber.length <= 1 && newNumber.isDigitsOnly()) {
+ val digit = newNumber.toIntOrNull()
+ onNumberChanged(digit)
+ if (digit != null) {
+ onNumberEntered()
+ }
+ }
+ },
+ cursorBrush = SolidColor(textColor),
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium.copy(
+ textAlign = TextAlign.Center,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ color = textColor,
+ lineHeight = 24.sp,
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword
+ ),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ innerTextField()
+ }
+ }
+ )
+ }
+}
+
+private fun getPreviousFocusedIndex(currentIndex: Int): Int? {
+ return currentIndex.minus(1).takeIf { it >= 0 }
+}
+
+private fun getNextFocusedIndex(code: List, currentIndex: Int): Int? {
+ if (currentIndex >= code.size - 1) return currentIndex
+
+ for (i in (currentIndex + 1) until code.size) {
+ if (code[i] == null) {
+ return i
+ }
+ }
+ return currentIndex
+}
+
+@Preview
+@Composable
+private fun PreviewVerificationCodeInputFieldExample() {
+ val completedCode = remember { mutableStateOf(null) }
+ val currentCode = remember { mutableStateOf("") }
+ val isError = remember { mutableStateOf(false) }
+
+ AuthUITheme {
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp),
+ isError = isError.value,
+ errorMessage = if (isError.value) "Invalid verification code" else null,
+ onCodeComplete = { code ->
+ completedCode.value = code
+ // Simulate validation - in real app this would be async
+ isError.value = code != "123456"
+ },
+ onCodeChange = { code ->
+ currentCode.value = code
+ // Clear error on change
+ if (isError.value) {
+ isError.value = false
+ }
+ }
+ )
+
+ if (!isError.value) {
+ completedCode.value?.let { code ->
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = "Code entered: $code",
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 16.sp,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewVerificationCodeInputFieldError() {
+ AuthUITheme {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp),
+ isError = true,
+ errorMessage = "Invalid verification code"
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PreviewVerificationCodeInputField() {
+ AuthUITheme {
+ VerificationCodeInputField(
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/credentials/CredentialSaveActivity.kt b/auth/src/main/java/com/firebase/ui/auth/ui/credentials/CredentialSaveActivity.kt
deleted file mode 100644
index c539de0c7..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/credentials/CredentialSaveActivity.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package com.firebase.ui.auth.ui.credentials
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.util.Log
-import androidx.lifecycle.ViewModelProvider
-import com.firebase.ui.auth.IdpResponse
-import com.firebase.ui.auth.data.model.FlowParameters
-import com.firebase.ui.auth.data.model.Resource
-import com.firebase.ui.auth.ui.InvisibleActivityBase
-import com.firebase.ui.auth.util.ExtraConstants
-import com.firebase.ui.auth.viewmodel.ResourceObserver
-import com.firebase.ui.auth.viewmodel.credentialmanager.CredentialManagerHandler
-import com.google.firebase.auth.FirebaseAuth
-
-class CredentialSaveActivity : InvisibleActivityBase() {
-
- private lateinit var credentialManagerHandler: CredentialManagerHandler
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val response: IdpResponse? = intent.getParcelableExtra(ExtraConstants.IDP_RESPONSE)
- val emailExtra: String? = intent.getStringExtra(ExtraConstants.EMAIL)
- val password: String? = intent.getStringExtra(ExtraConstants.PASSWORD)
-
- credentialManagerHandler = ViewModelProvider(this)
- .get(CredentialManagerHandler::class.java)
- .apply {
- // Initialize with flow parameters.
- init(flowParams)
- // Pass the IdP response if present.
- response?.let { setResponse(it) }
-
- // Observe the operation's result.
- operation.observe(
- this@CredentialSaveActivity,
- object : ResourceObserver(this@CredentialSaveActivity) {
- override fun onSuccess(response: IdpResponse) {
- finish(RESULT_OK, response.toIntent())
- }
-
- override fun onFailure(e: Exception) {
- // Even if saving fails, do not block the sign-in flow.
- response?.let {
- finish(RESULT_OK, it.toIntent())
- } ?: finish(RESULT_OK, null)
- }
- }
- )
- }
-
- val currentOp: Resource? = credentialManagerHandler.operation.value
-
- if (currentOp == null) {
- Log.d(TAG, "Launching save operation.")
- // With the new CredentialManager, pass the email and password directly.
- val firebaseUser = FirebaseAuth.getInstance().currentUser
- val email = firebaseUser?.email ?: emailExtra
-
- credentialManagerHandler.saveCredentials(this, firebaseUser, email, password)
- } else {
- Log.d(TAG, "Save operation in progress, doing nothing.")
- }
- }
-
- companion object {
- private const val TAG = "CredentialSaveActivity"
-
- @JvmStatic
- fun createIntent(
- context: Context,
- flowParams: FlowParameters,
- email: String,
- password: String?,
- response: IdpResponse
- ): Intent {
- return createBaseIntent(context, CredentialSaveActivity::class.java, flowParams).apply {
- putExtra(ExtraConstants.EMAIL, email)
- putExtra(ExtraConstants.PASSWORD, password)
- putExtra(ExtraConstants.IDP_RESPONSE, response)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailFragment.java
deleted file mode 100644
index b34ce6ffa..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailFragment.java
+++ /dev/null
@@ -1,238 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Intent;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.ImeHelper;
-import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator;
-import com.google.android.material.snackbar.Snackbar;
-import com.google.android.material.textfield.TextInputLayout;
-import com.google.firebase.auth.EmailAuthProvider;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-
-import static com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER;
-
-/**
- * Fragment that shows a form with an email field and checks for existing accounts with that email.
- *
- * Host Activities should implement {@link CheckEmailFragment.CheckEmailListener}.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class CheckEmailFragment extends FragmentBase implements
- View.OnClickListener,
- ImeHelper.DonePressedListener {
-
- public static final String TAG = "CheckEmailFragment";
- private CheckEmailHandler mHandler;
- private Button mSignInButton;
- private Button mSignUpButton;
- private ProgressBar mProgressBar;
- private EditText mEmailEditText;
- private TextInputLayout mEmailLayout;
- private EmailFieldValidator mEmailFieldValidator;
- private CheckEmailListener mListener;
-
- public static CheckEmailFragment newInstance(@Nullable String email) {
- CheckEmailFragment fragment = new CheckEmailFragment();
- Bundle args = new Bundle();
- args.putString(ExtraConstants.EMAIL, email);
- fragment.setArguments(args);
- return fragment;
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_check_email_layout, container, false);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mSignInButton = view.findViewById(R.id.button_sign_in);
- mSignUpButton = view.findViewById(R.id.button_sign_up);
- mProgressBar = view.findViewById(R.id.top_progress_bar);
-
- mEmailLayout = view.findViewById(R.id.email_layout);
- mEmailEditText = view.findViewById(R.id.email);
- mEmailFieldValidator = new EmailFieldValidator(mEmailLayout);
- mEmailLayout.setOnClickListener(this);
- mEmailEditText.setOnClickListener(this);
-
- TextView headerText = view.findViewById(R.id.header_text);
- if (headerText != null) {
- headerText.setVisibility(View.GONE);
- }
-
- ImeHelper.setImeOnDoneListener(mEmailEditText, this);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- mEmailEditText.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
- }
-
- // Set listeners for our new sign‑in and sign‑up buttons.
- mSignInButton.setOnClickListener(this);
- mSignUpButton.setOnClickListener(this);
-
- // Hide sign up button for email link authentication
- if (getEmailProvider().equals(EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD)) {
- mSignUpButton.setVisibility(View.GONE);
- }
-
- TextView termsText = view.findViewById(R.id.email_tos_and_pp_text);
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- FlowParameters flowParameters = getFlowParams();
-
- if (!flowParameters.shouldShowProviderChoice()) {
- PrivacyDisclosureUtils.setupTermsOfServiceAndPrivacyPolicyText(requireContext(),
- flowParameters,
- termsText);
- } else {
- termsText.setVisibility(View.GONE);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(),
- flowParameters,
- footerText);
- }
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- mHandler = new ViewModelProvider(this).get(CheckEmailHandler.class);
- mHandler.init(getFlowParams());
-
- FragmentActivity activity = getActivity();
- if (!(activity instanceof CheckEmailListener)) {
- throw new IllegalStateException("Activity must implement CheckEmailListener");
- }
- mListener = (CheckEmailListener) activity;
-
- // Removed the observer on mHandler.getOperation() since we no longer rely on provider info.
-
- if (savedInstanceState == null) {
- String email = getArguments().getString(ExtraConstants.EMAIL);
- if (!TextUtils.isEmpty(email)) {
- mEmailEditText.setText(email);
- // Previously auto-triggering the check is now removed.
- } else if (getFlowParams().enableCredentials) {
- mHandler.fetchCredential();
- }
- }
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- mHandler.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void onClick(View view) {
- int id = view.getId();
-
- if (id == R.id.button_sign_in) {
- signIn();
- } else if (id == R.id.button_sign_up) {
- signUp();
- } else if (id == R.id.email_layout || id == R.id.email) {
- mEmailLayout.setError(null);
- }
- }
-
- @Override
- public void onDonePressed() {
- // When the user hits “done” on the keyboard, default to sign‑in.
- signIn();
- }
-
- private String getEmailProvider() {
- // Iterate through all IdpConfig entries
- for (AuthUI.IdpConfig config : getFlowParams().providers) {
- // Assuming there is a getter for the provider ID
- if (EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD.equals(config.getProviderId())) {
- return EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD;
- }
- }
- // Default to standard email/password
- return EmailAuthProvider.PROVIDER_ID;
- }
-
- private void signIn() {
- String email = mEmailEditText.getText().toString();
- if (mEmailFieldValidator.validate(email)) {
- String provider = getEmailProvider();
- User user = new User.Builder(provider, email).build();
- mListener.onExistingEmailUser(user);
- }
- }
-
- private void signUp() {
- String email = mEmailEditText.getText().toString();
- if (mEmailFieldValidator.validate(email)) {
- String provider = getEmailProvider();
- User user = new User.Builder(provider, email).build();
- mListener.onNewUser(user);
- }
- }
-
- @Override
- public void showProgress(int message) {
- // Disable both buttons while progress is showing.
- mSignInButton.setEnabled(false);
- mSignUpButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mSignInButton.setEnabled(true);
- mSignUpButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-
- /**
- * Interface to be implemented by Activities hosting this Fragment.
- */
- interface CheckEmailListener {
-
- /**
- * Email entered belongs to an existing email user (sign‑in flow).
- */
- void onExistingEmailUser(User user);
-
- /**
- * Email entered belongs to an existing IDP user.
- */
- void onExistingIdpUser(User user);
-
- /**
- * Email entered does not belong to an existing user (sign‑up flow).
- */
- void onNewUser(User user);
-
- /**
- * Email entered corresponds to an existing user whose sign in methods we do not support.
- */
- void onDeveloperFailure(Exception e);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailHandler.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailHandler.java
deleted file mode 100644
index 3a911a417..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/CheckEmailHandler.java
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.app.Activity;
-import android.app.Application;
-import android.app.PendingIntent;
-import android.content.Intent;
-import android.util.Log;
-
-import com.firebase.ui.auth.data.model.PendingIntentRequiredException;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.viewmodel.AuthViewModelBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.android.gms.common.api.ApiException;
-import com.google.android.gms.tasks.Task;
-
-// New Identity API imports:
-import com.google.android.gms.auth.api.identity.BeginSignInRequest;
-import com.google.android.gms.auth.api.identity.SignInClient;
-import com.google.android.gms.auth.api.identity.SignInCredential;
-import com.google.android.gms.auth.api.identity.Identity;
-
-import androidx.annotation.Nullable;
-
-public class CheckEmailHandler extends AuthViewModelBase {
- private static final String TAG = "CheckEmailHandler";
-
- public CheckEmailHandler(Application application) {
- super(application);
- }
-
- /**
- * Initiates a hint picker flow using the new Identity API.
- * This replaces the deprecated Credentials API call.
- */
- public void fetchCredential() {
- // Build a sign-in request that supports password-based sign in,
- // which will trigger the hint picker UI for email addresses.
- SignInClient signInClient = Identity.getSignInClient(getApplication());
- BeginSignInRequest signInRequest = BeginSignInRequest.builder()
- .setPasswordRequestOptions(
- BeginSignInRequest.PasswordRequestOptions.builder()
- .setSupported(true)
- .build())
- .build();
-
- signInClient.beginSignIn(signInRequest)
- .addOnSuccessListener(result -> {
- // The new API returns a PendingIntent to launch the hint picker.
- PendingIntent pendingIntent = result.getPendingIntent();
- setResult(Resource.forFailure(
- new PendingIntentRequiredException(pendingIntent, RequestCodes.CRED_HINT)));
- })
- .addOnFailureListener(e -> {
- Log.e(TAG, "beginSignIn failed", e);
- setResult(Resource.forFailure(e));
- });
- }
-
- /**
- * Fetches the top provider for the given email.
- */
- public void fetchProvider(final String email) {
- setResult(Resource.forLoading());
- ProviderUtils.fetchTopProvider(getAuth(), getArguments(), email)
- .addOnCompleteListener(task -> {
- if (task.isSuccessful()) {
- setResult(Resource.forSuccess(
- new User.Builder(task.getResult(), email).build()));
- } else {
- setResult(Resource.forFailure(task.getException()));
- }
- });
- }
-
- /**
- * Handles the result from the hint picker launched via the new Identity API.
- */
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- if (requestCode != RequestCodes.CRED_HINT || resultCode != Activity.RESULT_OK) {
- return;
- }
-
- setResult(Resource.forLoading());
- SignInClient signInClient = Identity.getSignInClient(getApplication());
- try {
- // Retrieve the SignInCredential from the returned intent.
- SignInCredential credential = signInClient.getSignInCredentialFromIntent(data);
- final String email = credential.getId();
-
- ProviderUtils.fetchTopProvider(getAuth(), getArguments(), email)
- .addOnCompleteListener(task -> {
- if (task.isSuccessful()) {
- setResult(Resource.forSuccess(new User.Builder(task.getResult(), email)
- .setName(credential.getDisplayName())
- .setPhotoUri(credential.getProfilePictureUri())
- .build()));
- } else {
- setResult(Resource.forFailure(task.getException()));
- }
- });
- } catch (ApiException e) {
- Log.e(TAG, "getSignInCredentialFromIntent failed", e);
- setResult(Resource.forFailure(e));
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.java
deleted file mode 100644
index feabf506d..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailActivity.java
+++ /dev/null
@@ -1,247 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.ui.idp.WelcomeBackIdpPrompt;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.EmailLinkPersistenceManager;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.android.material.textfield.TextInputLayout;
-import com.google.firebase.auth.ActionCodeSettings;
-import com.google.firebase.auth.EmailAuthProvider;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringRes;
-import androidx.core.view.ViewCompat;
-import androidx.fragment.app.FragmentTransaction;
-
-import static com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER;
-
-/**
- * Activity to control the entire email sign up flow. Plays host to {@link CheckEmailFragment} and
- * {@link RegisterEmailFragment} and triggers {@link WelcomeBackPasswordPrompt} and {@link
- * WelcomeBackIdpPrompt}.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailActivity extends AppCompatBase implements CheckEmailFragment.CheckEmailListener,
- RegisterEmailFragment.AnonymousUpgradeListener, EmailLinkFragment
- .TroubleSigningInListener, TroubleSigningInFragment.ResendEmailListener {
-
- public static Intent createIntent(Context context, FlowParameters flowParams) {
- return createBaseIntent(context, EmailActivity.class, flowParams);
- }
-
- public static Intent createIntent(Context context, FlowParameters flowParams, String email) {
- return createBaseIntent(context, EmailActivity.class, flowParams)
- .putExtra(ExtraConstants.EMAIL, email);
- }
-
- public static Intent createIntentForLinking(Context context, FlowParameters flowParams,
- IdpResponse responseForLinking) {
- return createIntent(context, flowParams, responseForLinking.getEmail())
- .putExtra(ExtraConstants.IDP_RESPONSE, responseForLinking);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_activity_register_email);
-
- if (savedInstanceState != null) {
- return;
- }
-
- // Get email from intent (can be null)
- String email = getIntent().getExtras().getString(ExtraConstants.EMAIL);
-
- IdpResponse responseForLinking = getIntent().getExtras().getParcelable(ExtraConstants
- .IDP_RESPONSE);
- if (email != null && responseForLinking != null) {
- // got here from WelcomeBackEmailLinkPrompt
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdpsOrThrow(
- getFlowParams().providers, EMAIL_LINK_PROVIDER);
- ActionCodeSettings actionCodeSettings = emailConfig.getParams().getParcelable
- (ExtraConstants.ACTION_CODE_SETTINGS);
-
- EmailLinkPersistenceManager.getInstance().saveIdpResponseForLinking(getApplication(),
- responseForLinking);
-
- boolean forceSameDevice =
- emailConfig.getParams().getBoolean(ExtraConstants.FORCE_SAME_DEVICE);
- EmailLinkFragment fragment = EmailLinkFragment.newInstance(email, actionCodeSettings,
- responseForLinking, forceSameDevice);
- switchFragment(fragment, R.id.fragment_register_email, EmailLinkFragment.TAG);
- } else {
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdps(
- getFlowParams().providers, EmailAuthProvider.PROVIDER_ID);
-
- if (emailConfig != null) {
- email = emailConfig.getParams().getString(ExtraConstants.DEFAULT_EMAIL);;
- }
- // Start with check email
- CheckEmailFragment fragment = CheckEmailFragment.newInstance(email);
- switchFragment(fragment, R.id.fragment_register_email, CheckEmailFragment.TAG);
- }
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == RequestCodes.WELCOME_BACK_EMAIL_FLOW
- || requestCode == RequestCodes.WELCOME_BACK_IDP_FLOW) {
- finish(resultCode, data);
- }
- }
-
- @Override
- public void onExistingEmailUser(User user) {
- if (user.getProviderId().equals(EMAIL_LINK_PROVIDER)) {
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdpsOrThrow(
- getFlowParams().providers, EMAIL_LINK_PROVIDER);
- showRegisterEmailLinkFragment(
- emailConfig, user.getEmail());
- } else {
- startActivityForResult(
- WelcomeBackPasswordPrompt.createIntent(
- this, getFlowParams(), new IdpResponse.Builder(user).build()),
- RequestCodes.WELCOME_BACK_EMAIL_FLOW);
- setSlideAnimation();
- }
- }
-
- @Override
- public void onExistingIdpUser(User user) {
- // Existing social user, direct them to sign in using their chosen provider.
- startActivityForResult(
- WelcomeBackIdpPrompt.createIntent(this, getFlowParams(), user),
- RequestCodes.WELCOME_BACK_IDP_FLOW);
- setSlideAnimation();
- }
-
- @Override
- public void onNewUser(User user) {
- // New user, direct them to create an account with email/password
- // if account creation is enabled in SignInIntentBuilder
-
- TextInputLayout emailLayout = findViewById(R.id.email_layout);
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdps(getFlowParams().providers,
- EmailAuthProvider.PROVIDER_ID);
-
- if (emailConfig == null) {
- emailConfig = ProviderUtils.getConfigFromIdps(getFlowParams().providers,
- EMAIL_LINK_PROVIDER);
- }
-
- if (emailConfig.getParams().getBoolean(ExtraConstants.ALLOW_NEW_EMAILS, true)) {
- FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
- if (emailConfig.getProviderId().equals(EMAIL_LINK_PROVIDER)) {
- showRegisterEmailLinkFragment(emailConfig, user.getEmail());
- } else {
- RegisterEmailFragment fragment = RegisterEmailFragment.newInstance(user);
- ft.replace(R.id.fragment_register_email, fragment, RegisterEmailFragment.TAG);
- if (emailLayout != null) {
- String emailFieldName = getString(R.string.fui_email_field_name);
- ViewCompat.setTransitionName(emailLayout, emailFieldName);
- ft.addSharedElement(emailLayout, emailFieldName);
- }
- ft.disallowAddToBackStack().commit();
- }
- } else {
- emailLayout.setError(getString(R.string.fui_error_email_does_not_exist));
- }
- }
-
- @Override
- public void onTroubleSigningIn(String email) {
- TroubleSigningInFragment troubleSigningInFragment = TroubleSigningInFragment.newInstance
- (email);
- switchFragment(troubleSigningInFragment, R.id.fragment_register_email,
- TroubleSigningInFragment.TAG, true, true);
- }
-
- @Override
- public void onClickResendEmail(String email) {
- if (getSupportFragmentManager().getBackStackEntryCount() > 0) {
- // We're assuming that to get to the TroubleSigningInFragment, we went through
- // the EmailLinkFragment, which was added to the fragment back stack.
- // From here, we're going to register the EmailLinkFragment again, meaning we'd have to
- // pop off the back stack twice to return to the nascar screen. To avoid this,
- // we pre-emptively pop off the last EmailLinkFragment here.
- getSupportFragmentManager().popBackStack();
- }
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdpsOrThrow(
- getFlowParams().providers, EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD);
- showRegisterEmailLinkFragment(
- emailConfig, email);
- }
-
- @Override
- public void onSendEmailFailure(Exception e) {
- finishOnDeveloperError(e);
- }
-
- @Override
- public void onDeveloperFailure(Exception e) {
- finishOnDeveloperError(e);
- }
-
- private void finishOnDeveloperError(Exception e) {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(new FirebaseUiException(
- ErrorCodes.DEVELOPER_ERROR, e.getMessage())));
- }
-
- private void setSlideAnimation() {
- // Make the next activity slide in
- overridePendingTransition(R.anim.fui_slide_in_right, R.anim.fui_slide_out_left);
- }
-
- private void showRegisterEmailLinkFragment(AuthUI.IdpConfig emailConfig,
- String email) {
- ActionCodeSettings actionCodeSettings = emailConfig.getParams().getParcelable
- (ExtraConstants.ACTION_CODE_SETTINGS);
- EmailLinkFragment fragment = EmailLinkFragment.newInstance(email,
- actionCodeSettings);
- switchFragment(fragment, R.id.fragment_register_email, EmailLinkFragment.TAG);
- }
-
- @Override
- public void showProgress(@StringRes int message) {
- throw new UnsupportedOperationException("Email fragments must handle progress updates.");
- }
-
- @Override
- public void hideProgress() {
- throw new UnsupportedOperationException("Email fragments must handle progress updates.");
- }
-
- @Override
- public void onMergeFailure(IdpResponse response) {
- finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, response.toIntent());
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCatcherActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCatcherActivity.java
deleted file mode 100644
index 50a7bfb0f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCatcherActivity.java
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.app.AlertDialog;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.UserCancellationException;
-import com.firebase.ui.auth.ui.InvisibleActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.EmailLinkSignInHandler;
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailLinkCatcherActivity extends InvisibleActivityBase {
-
- private EmailLinkSignInHandler mHandler;
-
- public static Intent createIntent(Context context, FlowParameters flowParams) {
- return createBaseIntent(context, EmailLinkCatcherActivity.class, flowParams);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- initHandler();
-
- if (getFlowParams().emailLink != null) {
- mHandler.startSignIn();
- }
- }
-
- private void initHandler() {
- mHandler = new ViewModelProvider(this).get(EmailLinkSignInHandler.class);
- mHandler.init(getFlowParams());
- mHandler.getOperation().observe(this, new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- finish(RESULT_OK, response.toIntent());
- }
-
- @Override
- protected void onFailure(@NonNull final Exception e) {
- if (e instanceof UserCancellationException) {
- finish(RESULT_CANCELED, null);
- } else if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse res = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- finish(RESULT_CANCELED, new Intent().putExtra(ExtraConstants.IDP_RESPONSE, res));
- } else if (e instanceof FirebaseUiException) {
- int errorCode = ((FirebaseUiException) e).getErrorCode();
- if (errorCode == ErrorCodes.EMAIL_LINK_WRONG_DEVICE_ERROR
- || errorCode == ErrorCodes.INVALID_EMAIL_LINK_ERROR
- || errorCode == ErrorCodes.EMAIL_LINK_DIFFERENT_ANONYMOUS_USER_ERROR) {
- buildAlertDialog(errorCode).show();
- } else if (errorCode == ErrorCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_ERROR
- || errorCode == ErrorCodes.EMAIL_MISMATCH_ERROR) {
- startErrorRecoveryFlow(RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW);
- } else if (errorCode == ErrorCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_ERROR) {
- startErrorRecoveryFlow(RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW);
- }
- } else if (e instanceof FirebaseAuthInvalidCredentialsException) {
- startErrorRecoveryFlow(RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW);
- } else {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(e));
- }
- }
- });
- }
-
- /**
- * @param flow must be one of RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW or
- * RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW
- */
- private void startErrorRecoveryFlow(int flow) {
- if (flow != RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW
- && flow != RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW) {
- throw new IllegalStateException("Invalid flow param. It must be either " +
- "RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW or " +
- "RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW");
- }
- Intent intent = EmailLinkErrorRecoveryActivity.createIntent(getApplicationContext(),
- getFlowParams(), flow);
- startActivityForResult(intent, flow);
- }
-
- private AlertDialog buildAlertDialog(final int errorCode) {
- AlertDialog.Builder alertDialog = new AlertDialog.Builder(this);
-
- String titleText;
- String messageText;
- if (errorCode == ErrorCodes.EMAIL_LINK_DIFFERENT_ANONYMOUS_USER_ERROR) {
- titleText = getString(R.string.fui_email_link_different_anonymous_user_header);
- messageText = getString(R.string.fui_email_link_different_anonymous_user_message);
- } else if (errorCode == ErrorCodes.INVALID_EMAIL_LINK_ERROR) {
- titleText = getString(R.string.fui_email_link_invalid_link_header);
- messageText = getString(R.string.fui_email_link_invalid_link_message);
- } else {
- // Default value - ErrorCodes.EMAIL_LINK_WRONG_DEVICE_ERROR
- titleText = getString(R.string.fui_email_link_wrong_device_header);
- messageText = getString(R.string.fui_email_link_wrong_device_message);
- }
-
- return alertDialog.setTitle(titleText)
- .setMessage(messageText)
- .setPositiveButton(R.string.fui_email_link_dismiss_button,
- (dialog, id) -> finish(errorCode, null))
- .create();
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == RequestCodes.EMAIL_LINK_PROMPT_FOR_EMAIL_FLOW
- || requestCode == RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW) {
- IdpResponse response = IdpResponse.fromResultIntent(data);
- // CheckActionCode is called before starting this flow, so we only get here
- // if the sign in link is valid - it can only fail by being cancelled.
- if (resultCode == RESULT_OK) {
- finish(RESULT_OK, response.toIntent());
- } else {
- finish(RESULT_CANCELED, null);
- }
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCrossDeviceLinkingFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCrossDeviceLinkingFragment.java
deleted file mode 100644
index d22da246f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkCrossDeviceLinkingFragment.java
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.annotation.SuppressLint;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.SpannableStringBuilder;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.data.EmailLinkParser;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.util.ui.TextHelper;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-
-/**
- * Fragment that tells the user that a linking flow cannot be completed as they have opened the
- * email link on a different device.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailLinkCrossDeviceLinkingFragment extends FragmentBase
- implements View.OnClickListener {
-
- public static final String TAG = "CrossDeviceFragment";
-
- private FinishEmailLinkSignInListener mListener;
- private ProgressBar mProgressBar;
- private Button mContinueButton;
-
- public static EmailLinkCrossDeviceLinkingFragment newInstance() {
- EmailLinkCrossDeviceLinkingFragment fragment = new EmailLinkCrossDeviceLinkingFragment();
- return fragment;
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_email_link_cross_device_linking, container, false);
- }
-
- @SuppressWarnings("WrongConstant")
- @Override
- @SuppressLint("WrongConstant")
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mProgressBar = view.findViewById(R.id.top_progress_bar);
- mContinueButton = view.findViewById(R.id.button_continue);
- mContinueButton.setOnClickListener(this);
-
- String link = getFlowParams().emailLink;
-
- EmailLinkParser parser = new EmailLinkParser(link);
-
- String providerId = parser.getProviderId();
- String providerName = ProviderUtils.providerIdToProviderName(providerId);
-
- TextView body = view.findViewById(R.id.cross_device_linking_body);
- String bodyText = getString(R.string.fui_email_link_cross_device_linking_text,
- providerName);
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText);
- TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText, providerName);
- body.setText(spannableStringBuilder);
-
- // Justifies the text
- if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- body.setJustificationMode(android.text.Layout.JUSTIFICATION_MODE_INTER_WORD);
- }
-
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(), getFlowParams(),
- footerText);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
-
- FragmentActivity activity = getActivity();
- if (!(activity instanceof FinishEmailLinkSignInListener)) {
- throw new IllegalStateException("Activity must implement EmailLinkPromptEmailListener");
- }
- mListener = (FinishEmailLinkSignInListener) activity;
- }
-
- @Override
- public void onClick(View view) {
- int id = view.getId();
- if (id == R.id.button_continue) {
- mListener.completeCrossDeviceEmailLinkFlow();
- }
- }
-
- @Override
- public void showProgress(int message) {
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-
-
- /**
- * Interface to be implemented by Activities hosting this Fragment.
- */
- interface FinishEmailLinkSignInListener {
- /**
- * Used to let the hosting activity know that we can finish the email link sign in flow
- */
- void completeCrossDeviceEmailLinkFlow();
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkErrorRecoveryActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkErrorRecoveryActivity.java
deleted file mode 100644
index 01e5a199b..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkErrorRecoveryActivity.java
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringRes;
-import androidx.fragment.app.Fragment;
-
-/**
- * Handles the recovery flow for finishing the cross-device email link sign in flow. We either
- * need the user to input their email, or we need them to determine if they want to continue
- * the linking flow.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailLinkErrorRecoveryActivity extends AppCompatBase
- implements EmailLinkPromptEmailFragment.EmailLinkPromptEmailListener,
- EmailLinkCrossDeviceLinkingFragment.FinishEmailLinkSignInListener {
-
- private static final String RECOVERY_TYPE_KEY = "com.firebase.ui.auth.ui.email.recoveryTypeKey";
-
- public static Intent createIntent(Context context, FlowParameters flowParams, int flow) {
- return createBaseIntent(context, EmailLinkErrorRecoveryActivity.class, flowParams)
- .putExtra(RECOVERY_TYPE_KEY, flow);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_activity_register_email);
-
- if (savedInstanceState != null) {
- return;
- }
-
- boolean linkingFlow = getIntent().getIntExtra(RECOVERY_TYPE_KEY, -1) ==
- RequestCodes.EMAIL_LINK_CROSS_DEVICE_LINKING_FLOW;
-
- Fragment fragment;
- if (linkingFlow) {
- fragment = EmailLinkCrossDeviceLinkingFragment.newInstance();
- } else {
- fragment = EmailLinkPromptEmailFragment.newInstance();
- }
- switchFragment(fragment, R.id.fragment_register_email, EmailLinkPromptEmailFragment.TAG);
- }
-
- @Override
- public void onEmailPromptSuccess(IdpResponse response) {
- finish(RESULT_OK, response.toIntent());
- }
-
- @Override
- public void completeCrossDeviceEmailLinkFlow() {
- EmailLinkPromptEmailFragment fragment
- = EmailLinkPromptEmailFragment.newInstance();
- switchFragment(fragment, R.id.fragment_register_email,
- EmailLinkCrossDeviceLinkingFragment.TAG, /*withTransition=*/true,
- /*addToBackStack=*/true);
- }
-
- @Override
- public void showProgress(@StringRes int message) {
- throw new UnsupportedOperationException("Fragments must handle progress updates.");
- }
-
- @Override
- public void hideProgress() {
- throw new UnsupportedOperationException("Fragments must handle progress updates.");
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkFragment.java
deleted file mode 100644
index 27ce2ddbe..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkFragment.java
+++ /dev/null
@@ -1,174 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.text.SpannableStringBuilder;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ScrollView;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.InvisibleFragmentBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.TextHelper;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.EmailLinkSendEmailHandler;
-import com.google.firebase.auth.ActionCodeSettings;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailLinkFragment extends InvisibleFragmentBase {
-
- public static final String TAG = "EmailLinkFragment";
- private static final String EMAIL_SENT = "emailSent";
- private EmailLinkSendEmailHandler mEmailLinkSendEmailHandler;
- private TroubleSigningInListener mListener;
- private ScrollView mTopLevelView;
-
- // Used to avoid sending a new email when popping off the fragment backstack
- private boolean mEmailSent;
-
- public static EmailLinkFragment newInstance(@NonNull final String email,
- @NonNull final ActionCodeSettings settings) {
- return newInstance(email, settings, /*idpResponseForLinking=*/null, false);
- }
-
- public static EmailLinkFragment newInstance(@NonNull final String email,
- @NonNull final ActionCodeSettings
- actionCodeSettings,
- @Nullable final IdpResponse idpResponseForLinking,
- final boolean forceSameDevice) {
- EmailLinkFragment fragment = new EmailLinkFragment();
- Bundle args = new Bundle();
- args.putString(ExtraConstants.EMAIL, email);
- args.putParcelable(ExtraConstants.ACTION_CODE_SETTINGS, actionCodeSettings);
- args.putParcelable(ExtraConstants.IDP_RESPONSE, idpResponseForLinking);
- args.putBoolean(ExtraConstants.FORCE_SAME_DEVICE, forceSameDevice);
- fragment.setArguments(args);
- return fragment;
- }
-
- @Override
- public void onAttach(Context context) {
- super.onAttach(context);
- FragmentActivity activity = getActivity();
- if (!(activity instanceof TroubleSigningInListener)) {
- throw new IllegalStateException("Activity must implement TroubleSigningInListener");
- }
- mListener = (TroubleSigningInListener) activity;
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_email_link_sign_in_layout, container, false);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- if (savedInstanceState != null) {
- mEmailSent = savedInstanceState.getBoolean(EMAIL_SENT);
- }
-
- mTopLevelView = view.findViewById(R.id.top_level_view);
- if (!mEmailSent) {
- // We need to hide the top level view until we know that the email link has been sent
- mTopLevelView.setVisibility(View.GONE);
- }
-
- String email = getArguments().getString(ExtraConstants.EMAIL);
- setBodyText(view, email);
- setOnClickListeners(view, email);
- setPrivacyFooter(view);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- initHandler();
-
- String email = getArguments().getString(ExtraConstants.EMAIL);
- ActionCodeSettings actionCodeSettings
- = getArguments().getParcelable(ExtraConstants.ACTION_CODE_SETTINGS);
- IdpResponse idpResponseForLinking
- = getArguments().getParcelable(ExtraConstants.IDP_RESPONSE);
- boolean forceSameDevice
- = getArguments().getBoolean(ExtraConstants.FORCE_SAME_DEVICE);
-
- if (!mEmailSent) {
- mEmailLinkSendEmailHandler.sendSignInLinkToEmail(email, actionCodeSettings,
- idpResponseForLinking, forceSameDevice);
- }
- }
-
- private void initHandler() {
- mEmailLinkSendEmailHandler = new ViewModelProvider(this).get(EmailLinkSendEmailHandler
- .class);
- mEmailLinkSendEmailHandler.init(getFlowParams());
-
- mEmailLinkSendEmailHandler.getOperation().observe(getViewLifecycleOwner(), new ResourceObserver(this,
- R.string.fui_progress_dialog_sending) {
- @Override
- protected void onSuccess(@NonNull String email) {
- Log.w(TAG, "Email for email link sign in sent successfully.");
- doAfterTimeout(() -> mTopLevelView.setVisibility(View.VISIBLE));
- mEmailSent = true;
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- mListener.onSendEmailFailure(e);
- }
- });
- }
-
- private void setBodyText(View view, final String email) {
- TextView body = view.findViewById(R.id.sign_in_email_sent_text);
- String bodyText = getString(R.string.fui_email_link_email_sent, email);
-
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText);
- TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText, email);
- body.setText(spannableStringBuilder);
- }
-
- private void setOnClickListeners(View view, final String email) {
- view.findViewById(R.id.trouble_signing_in).setOnClickListener(v -> mListener.onTroubleSigningIn(email));
- }
-
- private void setPrivacyFooter(View view) {
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(), getFlowParams(),
- footerText);
- }
-
- @Override
- public void onSaveInstanceState(Bundle state) {
- super.onSaveInstanceState(state);
- state.putBoolean(EMAIL_SENT, mEmailSent);
- }
-
- interface TroubleSigningInListener {
- /**
- * User clicks on trouble signing in.
- */
- void onTroubleSigningIn(String email);
-
- /**
- * Failure occurs when trying to send the email.
- */
- void onSendEmailFailure(Exception e);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkPromptEmailFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkPromptEmailFragment.java
deleted file mode 100644
index 4e55594db..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/EmailLinkPromptEmailFragment.java
+++ /dev/null
@@ -1,148 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.EmailLinkSignInHandler;
-import com.google.android.material.textfield.TextInputLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-
-/** Prompts the user to enter their email to finish the cross-device email link sign in flow. */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class EmailLinkPromptEmailFragment extends FragmentBase implements
- View.OnClickListener {
-
- public static final String TAG = "EmailLinkPromptEmailFragment";
-
- private Button mNextButton;
- private Button mSignUpButton;
- private ProgressBar mProgressBar;
-
- private EditText mEmailEditText;
- private TextInputLayout mEmailLayout;
- private EmailFieldValidator mEmailFieldValidator;
-
- private EmailLinkSignInHandler mHandler;
- private EmailLinkPromptEmailListener mListener;
-
- public static EmailLinkPromptEmailFragment newInstance() {
- EmailLinkPromptEmailFragment fragment = new EmailLinkPromptEmailFragment();
- return fragment;
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_check_email_layout, container, false);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mNextButton = view.findViewById(R.id.button_sign_in);
- mSignUpButton = view.findViewById(R.id.button_sign_up);
- mProgressBar = view.findViewById(R.id.top_progress_bar);
-
- mNextButton.setOnClickListener(this);
-
- // Email field and validator
- mEmailLayout = view.findViewById(R.id.email_layout);
- mEmailEditText = view.findViewById(R.id.email);
- mEmailFieldValidator = new EmailFieldValidator(mEmailLayout);
- mEmailLayout.setOnClickListener(this);
- mEmailEditText.setOnClickListener(this);
-
- // Set activity title
- getActivity().setTitle(R.string.fui_email_link_confirm_email_header);
-
- // Set Tos/Pp footer
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(), getFlowParams(),
- footerText);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
-
- FragmentActivity activity = getActivity();
- if (!(activity instanceof EmailLinkPromptEmailListener)) {
- throw new IllegalStateException("Activity must implement EmailLinkPromptEmailListener");
- }
- mListener = (EmailLinkPromptEmailListener) activity;
-
- initHandler();
- }
-
- private void initHandler() {
- mHandler = new ViewModelProvider(this).get(EmailLinkSignInHandler.class);
- mHandler.init(getFlowParams());
- mHandler.getOperation().observe(getViewLifecycleOwner(), new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- mListener.onEmailPromptSuccess(response);
- }
-
- @Override
- protected void onFailure(@NonNull final Exception e) {
- // We've checked the oob code before starting this flow via #checkActionCode.
- // I don't see this failing in a non-recoverable way.
- mEmailLayout.setError(e.getMessage());
- }
- });
- }
-
- private void validateEmailAndFinishSignIn() {
- String email = mEmailEditText.getText().toString();
- if (mEmailFieldValidator.validate(email)) {
- mHandler.finishSignIn(email);
- }
- }
-
- @Override
- public void onClick(View view) {
- int id = view.getId();
- if (id == R.id.button_sign_in) {
- validateEmailAndFinishSignIn();
- } else if (id == R.id.email_layout || id == R.id.email) {
- mEmailLayout.setError(null);
- }
- }
-
- @Override
- public void showProgress(int message) {
- mNextButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mNextButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-
- /**
- * Interface to be implemented by Activities hosting this Fragment.
- */
- interface EmailLinkPromptEmailListener {
- /* Pass on the success to the hosting Activity so we can complete the sign in */
- void onEmailPromptSuccess(IdpResponse response);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java
deleted file mode 100644
index 5d1164b5f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RecoverPasswordActivity.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.ImeHelper;
-import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.RecoverPasswordHandler;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import com.google.android.material.textfield.TextInputLayout;
-import com.google.firebase.auth.ActionCodeSettings;
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException;
-import com.google.firebase.auth.FirebaseAuthInvalidUserException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-/**
- * Activity to initiate the "forgot password" flow by asking for the user's email.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class RecoverPasswordActivity extends AppCompatBase implements View.OnClickListener,
- ImeHelper.DonePressedListener {
- private RecoverPasswordHandler mHandler;
-
- private ProgressBar mProgressBar;
- private Button mSubmitButton;
- private TextInputLayout mEmailInputLayout;
- private EditText mEmailEditText;
- private EmailFieldValidator mEmailFieldValidator;
-
- public static Intent createIntent(Context context, FlowParameters params, String email) {
- return createBaseIntent(context, RecoverPasswordActivity.class, params)
- .putExtra(ExtraConstants.EMAIL, email);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_forgot_password_layout);
-
- mHandler = new ViewModelProvider(this).get(RecoverPasswordHandler.class);
- mHandler.init(getFlowParams());
- mHandler.getOperation().observe(this, new ResourceObserver(
- this, R.string.fui_progress_dialog_sending) {
- @Override
- protected void onSuccess(@NonNull String email) {
- mEmailInputLayout.setError(null);
- showEmailSentDialog(email);
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthInvalidUserException
- || e instanceof FirebaseAuthInvalidCredentialsException) {
- // No FirebaseUser exists with this email address, show error.
- mEmailInputLayout.setError(getString(R.string.fui_error_email_does_not_exist));
- } else {
- // Unknown error
- mEmailInputLayout.setError(getString(R.string.fui_error_unknown));
- }
- }
- });
-
- mProgressBar = findViewById(R.id.top_progress_bar);
- mSubmitButton = findViewById(R.id.button_done);
- mEmailInputLayout = findViewById(R.id.email_layout);
- mEmailEditText = findViewById(R.id.email);
- mEmailFieldValidator = new EmailFieldValidator(mEmailInputLayout);
-
- String email = getIntent().getStringExtra(ExtraConstants.EMAIL);
- if (email != null) {
- mEmailEditText.setText(email);
- }
-
- ImeHelper.setImeOnDoneListener(mEmailEditText, this);
- mSubmitButton.setOnClickListener(this);
-
- TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText);
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_done) {
- onDonePressed();
- }
- }
-
- @Override
- public void onDonePressed() {
- if (mEmailFieldValidator.validate(mEmailEditText.getText())) {
- if (getFlowParams().passwordResetSettings != null) {
- resetPassword(mEmailEditText.getText().toString(), getFlowParams().passwordResetSettings);
- }
- else {
- resetPassword(mEmailEditText.getText().toString(), null);
- }
- }
- }
-
- private void resetPassword(String email, @Nullable ActionCodeSettings passwordResetSettings) {
- mHandler.startReset(email, passwordResetSettings);
- }
- private void showEmailSentDialog(String email) {
- new MaterialAlertDialogBuilder(this)
- .setTitle(R.string.fui_title_confirm_recover_password)
- .setMessage(getString(R.string.fui_confirm_recovery_body, email))
- .setOnDismissListener(dialog -> finish(RESULT_OK, new Intent()))
- .setPositiveButton(android.R.string.ok, null)
- .show();
- }
-
- @Override
- public void showProgress(int message) {
- mSubmitButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mSubmitButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailFragment.java
deleted file mode 100644
index d1d2d21f4..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/RegisterEmailFragment.java
+++ /dev/null
@@ -1,288 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.os.Build;
-import android.os.Bundle;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.util.ui.ImeHelper;
-import com.firebase.ui.auth.util.ui.fieldvalidators.BaseValidator;
-import com.firebase.ui.auth.util.ui.fieldvalidators.EmailFieldValidator;
-import com.firebase.ui.auth.util.ui.fieldvalidators.NoOpValidator;
-import com.firebase.ui.auth.util.ui.fieldvalidators.PasswordFieldValidator;
-import com.firebase.ui.auth.util.ui.fieldvalidators.RequiredFieldValidator;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.EmailProviderResponseHandler;
-import com.google.android.material.textfield.TextInputLayout;
-import com.google.firebase.auth.EmailAuthProvider;
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException;
-import com.google.firebase.auth.FirebaseAuthWeakPasswordException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-import androidx.lifecycle.ViewModelProvider;
-
-/**
- * Fragment to display an email/name/password sign up form for new users.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class RegisterEmailFragment extends FragmentBase implements
- View.OnClickListener, View.OnFocusChangeListener, ImeHelper.DonePressedListener {
- public static final String TAG = "RegisterEmailFragment";
-
- private EmailProviderResponseHandler mHandler;
-
- private Button mNextButton;
- private ProgressBar mProgressBar;
-
- private EditText mEmailEditText;
- private EditText mNameEditText;
- private EditText mPasswordEditText;
- private TextInputLayout mEmailInput;
- private TextInputLayout mPasswordInput;
-
- private EmailFieldValidator mEmailFieldValidator;
- private PasswordFieldValidator mPasswordFieldValidator;
- private BaseValidator mNameValidator;
-
- private AnonymousUpgradeListener mListener;
- private User mUser;
-
- /**
- * Interface to be implemented by Activities hosting this Fragment.
- */
- interface AnonymousUpgradeListener {
-
- /**
- * Email belongs to an existing user - failed to merge anonymous user.
- */
- void onMergeFailure(IdpResponse response);
-
- }
-
- public static RegisterEmailFragment newInstance(User user) {
- RegisterEmailFragment fragment = new RegisterEmailFragment();
- Bundle args = new Bundle();
- args.putParcelable(ExtraConstants.USER, user);
- fragment.setArguments(args);
- return fragment;
- }
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- if (savedInstanceState == null) {
- mUser = User.getUser(getArguments());
- } else {
- mUser = User.getUser(savedInstanceState);
- }
-
- mHandler = new ViewModelProvider(this).get(EmailProviderResponseHandler.class);
- mHandler.init(getFlowParams());
- mHandler.getOperation().observe(this, new ResourceObserver(
- this, R.string.fui_progress_dialog_signing_up) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- startSaveCredentials(
- mHandler.getCurrentUser(),
- response,
- mPasswordEditText.getText().toString());
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthWeakPasswordException) {
- mPasswordInput.setError(getResources().getQuantityString(
- R.plurals.fui_error_weak_password,
- R.integer.fui_min_password_length));
- } else if (e instanceof FirebaseAuthInvalidCredentialsException) {
- mEmailInput.setError(getString(R.string.fui_invalid_email_address));
- } else if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse response = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- mListener.onMergeFailure(response);
- } else {
- // General error message, this branch should not be invoked but
- // covers future API changes
- mEmailInput.setError(getString(R.string.fui_email_account_creation_error));
- }
- }
- });
- }
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_register_email_layout, container, false);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mNextButton = view.findViewById(R.id.button_create);
- mProgressBar = view.findViewById(R.id.top_progress_bar);
-
- mEmailEditText = view.findViewById(R.id.email);
- mNameEditText = view.findViewById(R.id.name);
- mPasswordEditText = view.findViewById(R.id.password);
- mEmailInput = view.findViewById(R.id.email_layout);
- mPasswordInput = view.findViewById(R.id.password_layout);
- TextInputLayout nameInput = view.findViewById(R.id.name_layout);
-
- // Get configuration
- AuthUI.IdpConfig emailConfig = ProviderUtils.getConfigFromIdpsOrThrow(
- getFlowParams().providers, EmailAuthProvider.PROVIDER_ID);
- boolean requireName = emailConfig.getParams()
- .getBoolean(ExtraConstants.REQUIRE_NAME, true);
- mPasswordFieldValidator = new PasswordFieldValidator(
- mPasswordInput,
- getResources().getInteger(R.integer.fui_min_password_length));
- mNameValidator = requireName
- ? new RequiredFieldValidator(nameInput,
- getResources().getString(R.string.fui_missing_first_and_last_name))
- : new NoOpValidator(nameInput);
- mEmailFieldValidator = new EmailFieldValidator(mEmailInput);
-
- ImeHelper.setImeOnDoneListener(mPasswordEditText, this);
-
- mEmailEditText.setOnFocusChangeListener(this);
- mNameEditText.setOnFocusChangeListener(this);
- mPasswordEditText.setOnFocusChangeListener(this);
- mNextButton.setOnClickListener(this);
-
- // Only show the name field if required
- nameInput.setVisibility(requireName ? View.VISIBLE : View.GONE);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && getFlowParams().enableCredentials) {
- mEmailEditText.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
- }
-
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(
- requireContext(), getFlowParams(), footerText);
-
- // WARNING: Nothing below this line will be executed on rotation
- if (savedInstanceState != null) {
- return;
- }
-
- // If email is passed in, fill in the field and move down to the name field.
- String email = mUser.getEmail();
- if (!TextUtils.isEmpty(email)) {
- mEmailEditText.setText(email);
- }
-
- // If name is passed in, fill in the field and move down to the password field.
- String name = mUser.getName();
- if (!TextUtils.isEmpty(name)) {
- mNameEditText.setText(name);
- }
-
- // See http://stackoverflow.com/questions/11082341/android-requestfocus-ineffective#comment51774752_11082523
- if (!requireName || !TextUtils.isEmpty(mNameEditText.getText())) {
- safeRequestFocus(mPasswordEditText);
- } else if (!TextUtils.isEmpty(mEmailEditText.getText())) {
- safeRequestFocus(mNameEditText);
- } else {
- safeRequestFocus(mEmailEditText);
- }
- }
-
- private void safeRequestFocus(final View v) {
- v.post(() -> v.requestFocus());
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- FragmentActivity activity = requireActivity();
- activity.setTitle(R.string.fui_title_register_email);
- if (!(activity instanceof AnonymousUpgradeListener)) {
- throw new IllegalStateException("Activity must implement CheckEmailListener");
- }
- mListener = (AnonymousUpgradeListener) activity;
- }
-
- @Override
- public void onSaveInstanceState(@NonNull Bundle outState) {
- outState.putParcelable(ExtraConstants.USER,
- new User.Builder(EmailAuthProvider.PROVIDER_ID, mEmailEditText.getText().toString())
- .setName(mNameEditText.getText().toString())
- .setPhotoUri(mUser.getPhotoUri())
- .build());
- }
-
- @Override
- public void onFocusChange(View view, boolean hasFocus) {
- if (hasFocus) return; // Only consider fields losing focus
-
- int id = view.getId();
- if (id == R.id.email) {
- mEmailFieldValidator.validate(mEmailEditText.getText());
- } else if (id == R.id.name) {
- mNameValidator.validate(mNameEditText.getText());
- } else if (id == R.id.password) {
- mPasswordFieldValidator.validate(mPasswordEditText.getText());
- }
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_create) {
- validateAndRegisterUser();
- }
- }
-
- @Override
- public void onDonePressed() {
- validateAndRegisterUser();
- }
-
- @Override
- public void showProgress(int message) {
- mNextButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mNextButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-
- private void validateAndRegisterUser() {
- String email = mEmailEditText.getText().toString();
- String password = mPasswordEditText.getText().toString();
- String name = mNameEditText.getText().toString();
-
- boolean emailValid = mEmailFieldValidator.validate(email);
- boolean passwordValid = mPasswordFieldValidator.validate(password);
- boolean nameValid = mNameValidator.validate(name);
- if (emailValid && passwordValid && nameValid) {
- mHandler.startSignIn(new IdpResponse.Builder(
- new User.Builder(EmailAuthProvider.PROVIDER_ID, email)
- .setName(name)
- .setPhotoUri(mUser.getPhotoUri())
- .build())
- .build(),
- password);
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/TroubleSigningInFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/TroubleSigningInFragment.java
deleted file mode 100644
index 4b76e5926..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/TroubleSigningInFragment.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.fragment.app.FragmentActivity;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class TroubleSigningInFragment extends FragmentBase implements View.OnClickListener {
-
- public static final String TAG = "TroubleSigningInFragment";
-
- private ResendEmailListener mListener;
- private ProgressBar mProgressBar;
-
- private String mEmail;
-
- public static TroubleSigningInFragment newInstance(@NonNull final String email) {
- TroubleSigningInFragment fragment = new TroubleSigningInFragment();
- Bundle args = new Bundle();
- args.putString(ExtraConstants.EMAIL, email);
- fragment.setArguments(args);
- return fragment;
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_email_link_trouble_signing_in_layout, container,
- false);
- }
-
- @Override
- public void onAttach(Context context) {
- super.onAttach(context);
- FragmentActivity activity = getActivity();
- if (!(activity instanceof ResendEmailListener)) {
- throw new IllegalStateException("Activity must implement ResendEmailListener");
- }
- mListener = (ResendEmailListener) activity;
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mProgressBar = view.findViewById(R.id.top_progress_bar);
- mEmail = getArguments().getString(ExtraConstants.EMAIL);
-
- setOnClickListeners(view);
- setPrivacyFooter(view);
- }
-
- private void setOnClickListeners(View view) {
- view.findViewById(R.id.button_resend_email).setOnClickListener(this);
- }
-
- private void setPrivacyFooter(View view) {
- TextView footerText = view.findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(), getFlowParams(),
- footerText);
- }
-
- @Override
- public void onClick(View view) {
- if (view.getId() == R.id.button_resend_email) {
- mListener.onClickResendEmail(mEmail);
- }
- }
-
- @Override
- public void showProgress(int message) {
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-
- interface ResendEmailListener {
- /**
- * User clicks on the resend email button.
- */
- void onClickResendEmail(String email);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackEmailLinkPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackEmailLinkPrompt.java
deleted file mode 100644
index 2b62ff63c..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackEmailLinkPrompt.java
+++ /dev/null
@@ -1,118 +0,0 @@
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.SpannableStringBuilder;
-import android.view.View;
-import android.widget.Button;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.TextHelper;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class WelcomeBackEmailLinkPrompt extends AppCompatBase implements View.OnClickListener {
-
-
- private IdpResponse mIdpResponseForLinking;
- private Button mSignInButton;
- private ProgressBar mProgressBar;
-
-
- public static Intent createIntent(
- Context context, FlowParameters flowParams, IdpResponse response) {
- return createBaseIntent(context, WelcomeBackEmailLinkPrompt.class, flowParams)
- .putExtra(ExtraConstants.IDP_RESPONSE, response);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_welcome_back_email_link_prompt_layout);
- mIdpResponseForLinking = IdpResponse.fromResultIntent(getIntent());
- initializeViewObjects();
- setBodyText();
- setOnClickListeners();
- setPrivacyFooter();
- }
-
- private void startEmailLinkFlow() {
- Intent intent = EmailActivity.createIntentForLinking(this, getFlowParams(),
- mIdpResponseForLinking);
- startActivityForResult(intent, RequestCodes.WELCOME_BACK_EMAIL_LINK_FLOW);
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- finish(resultCode, data);
- }
-
- private void initializeViewObjects() {
- mSignInButton = findViewById(R.id.button_sign_in);
- mProgressBar = findViewById(R.id.top_progress_bar);
- }
-
- @SuppressWarnings("WrongConstant")
- private void setBodyText() {
- TextView body = findViewById(R.id.welcome_back_email_link_body);
- String bodyText = getString(R.string.fui_welcome_back_email_link_prompt_body,
- mIdpResponseForLinking.getEmail(),
- mIdpResponseForLinking
- .getProviderType());
-
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText);
- // bold email & provider text
- TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText,
- mIdpResponseForLinking.getEmail());
- TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText,
- mIdpResponseForLinking.getProviderType());
-
- body.setText(spannableStringBuilder);
- // Justifies the text
- if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- body.setJustificationMode(android.text.Layout.JUSTIFICATION_MODE_INTER_WORD);
- }
- }
-
- private void setOnClickListeners() {
- mSignInButton.setOnClickListener(this);
- }
-
- private void setPrivacyFooter() {
- TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText);
- }
-
- @Override
- public void onClick(View view) {
- final int id = view.getId();
- if (id == R.id.button_sign_in) {
- startEmailLinkFlow();
- }
- }
-
- @Override
- public void showProgress(int message) {
- mSignInButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mProgressBar.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java
deleted file mode 100644
index 7528152bd..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/WelcomeBackPasswordPrompt.java
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui.email;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.text.SpannableStringBuilder;
-import android.text.TextUtils;
-import android.view.View;
-import android.view.WindowManager;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.FirebaseAuthError;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.util.ui.ImeHelper;
-import com.firebase.ui.auth.util.ui.TextHelper;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.email.WelcomeBackPasswordHandler;
-import com.google.android.material.textfield.TextInputLayout;
-import com.google.firebase.auth.AuthCredential;
-import com.google.firebase.auth.FirebaseAuthException;
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringRes;
-import androidx.lifecycle.ViewModelProvider;
-
-/**
- * Activity to link a pre-existing email/password account to a new IDP sign-in by confirming the
- * password before initiating a link.
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class WelcomeBackPasswordPrompt extends AppCompatBase
- implements View.OnClickListener, ImeHelper.DonePressedListener {
- private IdpResponse mIdpResponse;
- private WelcomeBackPasswordHandler mHandler;
-
- private Button mDoneButton;
- private ProgressBar mProgressBar;
- private TextInputLayout mPasswordLayout;
- private EditText mPasswordField;
-
- public static Intent createIntent(
- Context context, FlowParameters flowParams, IdpResponse response) {
- return createBaseIntent(context, WelcomeBackPasswordPrompt.class, flowParams)
- .putExtra(ExtraConstants.IDP_RESPONSE, response);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_welcome_back_password_prompt_layout);
-
- // Show keyboard
- getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
-
- mIdpResponse = IdpResponse.fromResultIntent(getIntent());
- String email = mIdpResponse.getEmail();
-
- mDoneButton = findViewById(R.id.button_done);
- mProgressBar = findViewById(R.id.top_progress_bar);
- mPasswordLayout = findViewById(R.id.password_layout);
- mPasswordField = findViewById(R.id.password);
-
- ImeHelper.setImeOnDoneListener(mPasswordField, this);
-
- // Create welcome back text with email bolded.
- String bodyText =
- getString(R.string.fui_welcome_back_password_prompt_body, email);
-
- SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(bodyText);
- TextHelper.boldAllOccurencesOfText(spannableStringBuilder, bodyText, email);
-
- TextView bodyTextView = findViewById(R.id.welcome_back_password_body);
- bodyTextView.setText(spannableStringBuilder);
-
- // Click listeners
- mDoneButton.setOnClickListener(this);
- findViewById(R.id.trouble_signing_in).setOnClickListener(this);
-
- // Initialize ViewModel with arguments
- mHandler = new ViewModelProvider(this).get(WelcomeBackPasswordHandler.class);
- mHandler.init(getFlowParams());
-
- // Observe the state of the main auth operation
- mHandler.getOperation().observe(this, new ResourceObserver(
- this, R.string.fui_progress_dialog_signing_in) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- startSaveCredentials(
- mHandler.getCurrentUser(), response, mHandler.getPendingPassword());
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse response = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, response.toIntent());
- return;
- }
-
- if (e instanceof FirebaseAuthException) {
- FirebaseAuthException authEx = (FirebaseAuthException) e;
- FirebaseAuthError error = FirebaseAuthError.fromException(authEx);
- if (error == FirebaseAuthError.ERROR_USER_DISABLED) {
- IdpResponse resp = IdpResponse.from(
- new FirebaseUiException(ErrorCodes.ERROR_USER_DISABLED));
- finish(RESULT_CANCELED, resp.toIntent());
- return;
- }
- }
-
- mPasswordLayout.setError(getString(getErrorMessage(e)));
- }
- });
-
- TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText);
- }
-
- @StringRes
- private int getErrorMessage(Exception exception) {
- if (exception instanceof FirebaseAuthInvalidCredentialsException) {
- return R.string.fui_error_invalid_password;
- }
-
- return R.string.fui_error_unknown;
- }
-
- private void onForgotPasswordClicked() {
- startActivity(RecoverPasswordActivity.createIntent(
- this,
- getFlowParams(),
- mIdpResponse.getEmail()));
- }
-
- @Override
- public void onDonePressed() {
- validateAndSignIn();
- }
-
- private void validateAndSignIn() {
- validateAndSignIn(mPasswordField.getText().toString());
- }
-
- private void validateAndSignIn(String password) {
- // Check for null or empty password
- if (TextUtils.isEmpty(password)) {
- mPasswordLayout.setError(getString(R.string.fui_error_invalid_password));
- return;
- } else {
- mPasswordLayout.setError(null);
- }
-
- AuthCredential authCredential = ProviderUtils.getAuthCredential(mIdpResponse);
- mHandler.startSignIn(mIdpResponse.getEmail(), password, mIdpResponse, authCredential);
- }
-
- @Override
- public void onClick(View view) {
- final int id = view.getId();
- if (id == R.id.button_done) {
- validateAndSignIn();
- } else if (id == R.id.trouble_signing_in) {
- onForgotPasswordClicked();
- }
- }
-
- @Override
- public void showProgress(int message) {
- mDoneButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mDoneButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java b/auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java
deleted file mode 100644
index 0cdcb0450..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/email/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Activities related to the email and password based authentication.
- */
-package com.firebase.ui.auth.ui.email;
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt b/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt
deleted file mode 100644
index 5cec78510..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/AuthMethodPickerActivity.kt
+++ /dev/null
@@ -1,492 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui.idp
-
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.text.TextUtils
-import android.util.Log
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.ProgressBar
-import android.widget.TextView
-import android.widget.Toast
-import androidx.activity.result.ActivityResult
-import androidx.activity.result.IntentSenderRequest
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.constraintlayout.widget.ConstraintSet
-import androidx.core.view.isVisible
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.lifecycleScope
-import com.firebase.ui.auth.AuthMethodPickerLayout
-import com.firebase.ui.auth.AuthUI
-import com.firebase.ui.auth.AuthUI.IdpConfig
-import com.firebase.ui.auth.ErrorCodes
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException
-import com.firebase.ui.auth.FirebaseUiException
-import com.firebase.ui.auth.IdpResponse
-import com.firebase.ui.auth.KickoffActivity
-import com.firebase.ui.auth.R
-import com.firebase.ui.auth.data.model.FlowParameters
-import com.firebase.ui.auth.data.model.Resource
-import com.firebase.ui.auth.data.model.User
-import com.firebase.ui.auth.data.model.UserCancellationException
-import com.firebase.ui.auth.data.remote.AnonymousSignInHandler
-import com.firebase.ui.auth.data.remote.EmailSignInHandler
-import com.firebase.ui.auth.data.remote.FacebookSignInHandler
-import com.firebase.ui.auth.data.remote.GenericIdpSignInHandler
-import com.firebase.ui.auth.data.remote.GoogleSignInHandler
-import com.firebase.ui.auth.data.remote.PhoneSignInHandler
-import com.firebase.ui.auth.ui.AppCompatBase
-import com.firebase.ui.auth.util.ExtraConstants
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils
-import com.firebase.ui.auth.util.data.ProviderUtils
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase
-import com.firebase.ui.auth.viewmodel.ResourceObserver
-import com.firebase.ui.auth.viewmodel.idp.SocialProviderResponseHandler
-import com.google.android.gms.auth.api.identity.BeginSignInRequest
-import com.google.android.gms.auth.api.identity.Identity
-import com.google.android.gms.auth.api.identity.SignInCredential
-import com.google.android.gms.common.api.ApiException
-import com.google.android.material.snackbar.Snackbar
-import com.google.firebase.auth.EmailAuthProvider
-import com.google.firebase.auth.FacebookAuthProvider
-import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
-import com.google.firebase.auth.FirebaseAuthInvalidUserException
-import com.google.firebase.auth.GoogleAuthProvider
-import com.google.firebase.auth.PhoneAuthProvider
-import kotlinx.coroutines.launch
-
-// Imports for the new Credential Manager types (adjust these to match your library)
-import androidx.credentials.Credential
-import androidx.credentials.CredentialManager
-import androidx.credentials.CustomCredential
-import androidx.credentials.GetCredentialRequest
-import androidx.credentials.GetPasswordOption
-import androidx.credentials.PasswordCredential
-import androidx.credentials.PublicKeyCredential
-import androidx.credentials.exceptions.GetCredentialException
-
-import com.firebase.ui.auth.AuthUI.EMAIL_LINK_PROVIDER
-import com.firebase.ui.auth.util.ExtraConstants.GENERIC_OAUTH_BUTTON_ID
-import com.firebase.ui.auth.util.ExtraConstants.GENERIC_OAUTH_PROVIDER_ID
-import com.firebase.ui.auth.util.GoogleApiUtils
-import com.google.android.libraries.identity.googleid.GetGoogleIdOption
-import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
-import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
-import com.google.firebase.auth.GoogleAuthCredential
-
-@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP)
-class AuthMethodPickerActivity : AppCompatBase() {
-
- private lateinit var mHandler: SocialProviderResponseHandler
- private val mProviders: MutableList> = mutableListOf()
-
- private var mProgressBar: ProgressBar? = null
- private var mProviderHolder: ViewGroup? = null
-
- private var customLayout: AuthMethodPickerLayout? = null
-
- // For demonstration, assume that CredentialManager provides a create() method.
- private val credentialManager by lazy {
- // Replace with your actual CredentialManager instance creation.
- GoogleApiUtils.getCredentialManager(this)
- }
-
- companion object {
- private const val TAG = "AuthMethodPickerActivity"
-
- @JvmStatic
- fun createIntent(context: Context, flowParams: FlowParameters): Intent {
- return createBaseIntent(context, AuthMethodPickerActivity::class.java, flowParams)
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val params = flowParams
- customLayout = params.authMethodPickerLayout
-
- mHandler = ViewModelProvider(this).get(SocialProviderResponseHandler::class.java)
- mHandler.init(params)
-
- if (customLayout != null) {
- setContentView(customLayout!!.mainLayout)
- populateIdpListCustomLayout(params.providers)
- } else {
- setContentView(R.layout.fui_auth_method_picker_layout)
- mProgressBar = findViewById(R.id.top_progress_bar)
- mProviderHolder = findViewById(R.id.btn_holder)
- populateIdpList(params.providers)
-
- val logoId = params.logoId
- if (logoId == AuthUI.NO_LOGO) {
- findViewById(R.id.logo).visibility = View.GONE
-
- val layout = findViewById(R.id.root)
- val constraints = ConstraintSet()
- constraints.clone(layout)
- constraints.setHorizontalBias(R.id.container, 0.5f)
- constraints.setVerticalBias(R.id.container, 0.5f)
- constraints.applyTo(layout)
- } else {
- val logo = findViewById(R.id.logo)
- logo.setImageResource(logoId)
- }
- }
-
- val tosAndPpConfigured = flowParams.isPrivacyPolicyUrlProvided() &&
- flowParams.isTermsOfServiceUrlProvided()
-
- val termsTextId = if (customLayout == null) {
- R.id.main_tos_and_pp
- } else {
- customLayout!!.tosPpView
- }
-
- if (termsTextId >= 0) {
- val termsText = findViewById(termsTextId)
- if (!tosAndPpConfigured) {
- termsText.visibility = View.GONE
- } else {
- PrivacyDisclosureUtils.setupTermsOfServiceAndPrivacyPolicyText(this, flowParams, termsText)
- }
- }
-
- // Observe the social provider response handler.
- mHandler.operation.observe(this, object : ResourceObserver(this, R.string.fui_progress_dialog_signing_in) {
- override fun onSuccess(response: IdpResponse) {
- startSaveCredentials(mHandler.currentUser, response, null)
- }
-
- override fun onFailure(e: Exception) {
- when (e) {
- is UserCancellationException -> {
- // User pressed back – no error.
- }
- is FirebaseAuthAnonymousUpgradeException -> {
- finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, e.response.toIntent())
- }
- is FirebaseUiException -> {
- finish(RESULT_CANCELED, IdpResponse.from(e).toIntent())
- }
- else -> {
- val text = getString(R.string.fui_error_unknown)
- Toast.makeText(this@AuthMethodPickerActivity, text, Toast.LENGTH_SHORT).show()
- }
- }
- }
- })
-
- // Attempt sign in using the new Credential Manager API.
- attemptCredentialSignIn()
- }
-
- /**
- * Attempts to sign in automatically using the Credential Manager API.
- */
- private fun attemptCredentialSignIn() {
- val args = flowParams
- val supportPasswords = ProviderUtils.getConfigFromIdps(args.providers, EmailAuthProvider.PROVIDER_ID) != null
- val accountTypes = getCredentialAccountTypes()
- val willRequestCredentials = supportPasswords || accountTypes.isNotEmpty()
-
- if (args.enableCredentials && willRequestCredentials) {
- // Build the new Credential Manager request.
- val getPasswordOption = GetPasswordOption()
- val googleIdOption = GetGoogleIdOption.Builder()
- .setFilterByAuthorizedAccounts(true)
- .setServerClientId(getString(R.string.default_web_client_id))
- .build()
- val request = GetCredentialRequest(listOf(getPasswordOption, googleIdOption))
-
- lifecycleScope.launch {
- try {
- val result = credentialManager.getCredential(
- context = this@AuthMethodPickerActivity,
- request = request
- )
- // Handle the returned credential.
- handleCredentialManagerResult(result.credential)
- } catch (e: GetCredentialException) {
- handleCredentialManagerFailure(e)
- // Fallback: show the auth method picker.
- showAuthMethodPicker()
- }
- }
- } else {
- showAuthMethodPicker()
- }
- }
-
- /**
- * Handles the credential returned from the Credential Manager.
- */
- private fun handleCredentialManagerResult(credential: Credential) {
- when (credential) {
- is PasswordCredential -> {
- val username = credential.id
- val password = credential.password
- val response = IdpResponse.Builder(
- User.Builder(EmailAuthProvider.PROVIDER_ID, username).build()
- ).build()
- KickoffActivity.mKickstarter.setResult(Resource.forLoading())
- auth.signInWithEmailAndPassword(username, password)
- .addOnSuccessListener { authResult ->
- KickoffActivity.mKickstarter.handleSuccess(response, authResult)
- finish()
- }
- .addOnFailureListener { e ->
- if (e is FirebaseAuthInvalidUserException ||
- e is FirebaseAuthInvalidCredentialsException) {
- // Sign out via the new API.
- Identity.getSignInClient(application).signOut()
- }
- }
- }
- is CustomCredential -> {
- if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
- try {
- val googleIdTokenCredential = GoogleIdTokenCredential
- .createFrom(credential.data)
- auth.signInWithCredential(GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null))
- .addOnSuccessListener { authResult ->
- val response = IdpResponse.Builder(
- User.Builder(GoogleAuthProvider.PROVIDER_ID, googleIdTokenCredential.data.getString("email")).build(),
- ).setToken(googleIdTokenCredential.idToken).build()
- KickoffActivity.mKickstarter.handleSuccess(response, authResult)
- finish()
- }
- .addOnFailureListener { e ->
- Log.e(TAG, "Failed to sign in with Google ID token", e)
- }
- } catch (e: GoogleIdTokenParsingException) {
- Log.e(TAG, "Received an invalid google id token response", e)
- }
- } else {
- // Catch any unrecognized custom credential type here.
- Log.e(TAG, "Unexpected type of credential")
- }
- }
- else -> {
- Log.e(TAG, "Unexpected type of credential")
- }
- }
- }
-
- /**
- * Example helper to extract a Google ID token from a PublicKeyCredential.
- * In your implementation you may need to parse the JSON response accordingly.
- */
- private fun extractGoogleIdToken(credential: PublicKeyCredential): String? {
- // TODO: Extract and return the Google ID token from credential.authenticationResponseJson.
- // For demonstration, we assume that authenticationResponseJson is the token.
- return credential.authenticationResponseJson
- }
-
- private fun handleCredentialManagerFailure(e: GetCredentialException) {
- Log.e(TAG, "Credential Manager sign in failed", e)
- }
-
- /**
- * Returns the account types to pass to the credential manager.
- */
- private fun getCredentialAccountTypes(): List {
- val accounts = mutableListOf()
- for (idpConfig in flowParams.providers) {
- if (idpConfig.providerId == GoogleAuthProvider.PROVIDER_ID) {
- accounts.add(ProviderUtils.providerIdToAccountType(idpConfig.providerId))
- }
- }
- return accounts
- }
-
- /**
- * Fallback – show the auth method picker UI.
- */
- private fun showAuthMethodPicker() {
- hideProgress()
- }
-
- private fun populateIdpList(providerConfigs: List) {
- // Clear any previous providers.
- mProviders.clear()
- for (idpConfig in providerConfigs) {
- val buttonLayout = when (idpConfig.providerId) {
- GoogleAuthProvider.PROVIDER_ID -> R.layout.fui_idp_button_google
- FacebookAuthProvider.PROVIDER_ID -> R.layout.fui_idp_button_facebook
- EMAIL_LINK_PROVIDER, EmailAuthProvider.PROVIDER_ID -> R.layout.fui_provider_button_email
- PhoneAuthProvider.PROVIDER_ID -> R.layout.fui_provider_button_phone
- AuthUI.ANONYMOUS_PROVIDER -> R.layout.fui_provider_button_anonymous
- else -> {
- if (!TextUtils.isEmpty(idpConfig.params.getString(GENERIC_OAUTH_PROVIDER_ID))) {
- idpConfig.params.getInt(GENERIC_OAUTH_BUTTON_ID)
- } else {
- throw IllegalStateException("Unknown provider: ${idpConfig.providerId}")
- }
- }
- }
- val loginButton = layoutInflater.inflate(buttonLayout, mProviderHolder, false)
- handleSignInOperation(idpConfig, loginButton)
- mProviderHolder?.addView(loginButton)
- }
- }
-
- private fun populateIdpListCustomLayout(providerConfigs: List) {
- val providerButtonIds = customLayout?.providersButton ?: return
- for (idpConfig in providerConfigs) {
- val providerId = providerOrEmailLinkProvider(idpConfig.providerId)
- val buttonResId = providerButtonIds[providerId]
- ?: throw IllegalStateException("No button found for auth provider: ${idpConfig.providerId}")
- val loginButton = findViewById(buttonResId)
- handleSignInOperation(idpConfig, loginButton)
- }
- // Hide custom layout buttons that don't have an associated provider.
- for ((providerBtnId, resId) in providerButtonIds) {
- if (providerBtnId == null) continue
- var hasProvider = false
- for (idpConfig in providerConfigs) {
- if (providerOrEmailLinkProvider(idpConfig.providerId) == providerBtnId) {
- hasProvider = true
- break
- }
- }
- if (!hasProvider) {
- findViewById(resId)?.visibility = View.GONE
- }
- }
- }
-
- private fun providerOrEmailLinkProvider(providerId: String): String {
- return if (providerId == EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD) {
- EmailAuthProvider.PROVIDER_ID
- } else providerId
- }
-
- private fun handleSignInOperation(idpConfig: IdpConfig, view: View) {
- val providerId = idpConfig.providerId
- val authUI = getAuthUI()
- val viewModelProvider = ViewModelProvider(this)
- val provider: ProviderSignInBase<*> = when (providerId) {
- EMAIL_LINK_PROVIDER, EmailAuthProvider.PROVIDER_ID ->
- viewModelProvider.get(EmailSignInHandler::class.java).initWith(null)
- PhoneAuthProvider.PROVIDER_ID ->
- viewModelProvider.get(PhoneSignInHandler::class.java).initWith(idpConfig)
- AuthUI.ANONYMOUS_PROVIDER ->
- viewModelProvider.get(AnonymousSignInHandler::class.java).initWith(flowParams)
- GoogleAuthProvider.PROVIDER_ID ->
- if (authUI.isUseEmulator) {
- viewModelProvider.get(GenericIdpSignInHandler::class.java)
- .initWith(GenericIdpSignInHandler.getGenericGoogleConfig())
- } else {
- viewModelProvider.get(GoogleSignInHandler::class.java)
- .initWith(GoogleSignInHandler.Params(idpConfig))
- }
- FacebookAuthProvider.PROVIDER_ID ->
- if (authUI.isUseEmulator) {
- viewModelProvider.get(GenericIdpSignInHandler::class.java)
- .initWith(GenericIdpSignInHandler.getGenericFacebookConfig())
- } else {
- viewModelProvider.get(FacebookSignInHandler::class.java).initWith(idpConfig)
- }
- else -> {
- if (!TextUtils.isEmpty(idpConfig.params.getString(GENERIC_OAUTH_PROVIDER_ID))) {
- viewModelProvider.get(GenericIdpSignInHandler::class.java).initWith(idpConfig)
- } else {
- throw IllegalStateException("Unknown provider: $providerId")
- }
- }
- }
-
- mProviders.add(provider)
-
- provider.operation.observe(this, object : ResourceObserver(this) {
- override fun onSuccess(response: IdpResponse) {
- handleResponse(response)
- }
-
- override fun onFailure(e: Exception) {
- if (e is FirebaseAuthAnonymousUpgradeException) {
- finish(
- RESULT_CANCELED,
- Intent().putExtra(ExtraConstants.IDP_RESPONSE, IdpResponse.from(e))
- )
- return
- }
- handleResponse(IdpResponse.from(e))
- }
-
- private fun handleResponse(response: IdpResponse) {
- // For social providers (unless using an emulator) use the social response handler.
- val isSocialResponse = AuthUI.SOCIAL_PROVIDERS.contains(providerId) && !authUI.isUseEmulator
- if (!response.isSuccessful) {
- mHandler.startSignIn(response)
- } else if (isSocialResponse) {
- mHandler.startSignIn(response)
- } else {
- finish(if (response.isSuccessful) RESULT_OK else RESULT_CANCELED, response.toIntent())
- }
- }
- })
-
- view.setOnClickListener {
- if (isOffline()) {
- Snackbar.make(findViewById(android.R.id.content), getString(R.string.fui_no_internet), Snackbar.LENGTH_SHORT)
- .show()
- return@setOnClickListener
- }
- provider.startSignIn(getAuth(), this@AuthMethodPickerActivity, idpConfig.providerId)
- }
- }
-
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- super.onActivityResult(requestCode, resultCode, data)
- mHandler.onActivityResult(requestCode, resultCode, data)
- for (provider in mProviders) {
- provider.onActivityResult(requestCode, resultCode, data)
- }
- }
-
- override fun showProgress(message: Int) {
- if (customLayout == null) {
- mProgressBar?.visibility = View.VISIBLE
- mProviderHolder?.let { holder ->
- for (i in 0 until holder.childCount) {
- val child = holder.getChildAt(i)
- child.isEnabled = false
- child.alpha = 0.75f
- }
- }
- }
- }
-
- override fun hideProgress() {
- if (customLayout == null) {
- mProgressBar?.visibility = View.INVISIBLE
- mProviderHolder?.let { holder ->
- for (i in 0 until holder.childCount) {
- val child = holder.getChildAt(i)
- child.isEnabled = true
- child.alpha = 1.0f
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/SingleSignInActivity.java b/auth/src/main/java/com/firebase/ui/auth/ui/idp/SingleSignInActivity.java
deleted file mode 100644
index 17221f7ed..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/SingleSignInActivity.java
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.firebase.ui.auth.ui.idp;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.text.TextUtils;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.data.remote.FacebookSignInHandler;
-import com.firebase.ui.auth.data.remote.GenericIdpSignInHandler;
-import com.firebase.ui.auth.data.remote.GoogleSignInHandler;
-import com.firebase.ui.auth.ui.InvisibleActivityBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.idp.SocialProviderResponseHandler;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-import static com.firebase.ui.auth.util.ExtraConstants.GENERIC_OAUTH_PROVIDER_ID;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class SingleSignInActivity extends InvisibleActivityBase {
- private SocialProviderResponseHandler mHandler;
- private ProviderSignInBase> mProvider;
-
- public static Intent createIntent(Context context, FlowParameters flowParams, User user) {
- return createBaseIntent(context, SingleSignInActivity.class, flowParams)
- .putExtra(ExtraConstants.USER, user);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- User user = User.getUser(getIntent());
- final String provider = user.getProviderId();
-
- AuthUI.IdpConfig providerConfig =
- ProviderUtils.getConfigFromIdps(getFlowParams().providers, provider);
- if (providerConfig == null) {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(new FirebaseUiException(
- ErrorCodes.DEVELOPER_ERROR,
- "Provider not enabled: " + provider)));
- return;
- }
-
- ViewModelProvider supplier = new ViewModelProvider(this);
-
- mHandler = supplier.get(SocialProviderResponseHandler.class);
- mHandler.init(getFlowParams());
-
- boolean useEmulator = getAuthUI().isUseEmulator();
-
- switch (provider) {
- case GoogleAuthProvider.PROVIDER_ID:
- if (useEmulator) {
- mProvider = supplier.get(GenericIdpSignInHandler.class)
- .initWith(GenericIdpSignInHandler.getGenericGoogleConfig());
- } else {
- mProvider = supplier.get(GoogleSignInHandler.class).initWith(
- new GoogleSignInHandler.Params(providerConfig, user.getEmail()));
- }
- break;
- case FacebookAuthProvider.PROVIDER_ID:
- if (useEmulator) {
- mProvider = supplier.get(GenericIdpSignInHandler.class)
- .initWith(GenericIdpSignInHandler.getGenericFacebookConfig());
- } else {
- mProvider = supplier.get(FacebookSignInHandler.class).initWith(providerConfig);
- }
- break;
- default:
- if (!TextUtils.isEmpty(
- providerConfig.getParams().getString(GENERIC_OAUTH_PROVIDER_ID))) {
- mProvider = supplier.get(GenericIdpSignInHandler.class).initWith(providerConfig);
- break;
- }
- throw new IllegalStateException("Invalid provider id: " + provider);
- }
-
- mProvider.getOperation().observe(this, new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- boolean useSocialHandler = AuthUI.SOCIAL_PROVIDERS.contains(provider)
- && !getAuthUI().isUseEmulator();
-
- if (useSocialHandler || !response.isSuccessful()) {
- mHandler.startSignIn(response);
- return;
- }
- finish(response.isSuccessful() ? RESULT_OK : RESULT_CANCELED,
- response.toIntent());
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- finish(RESULT_CANCELED, new Intent().putExtra(ExtraConstants.IDP_RESPONSE,
- IdpResponse.from(e)));
- return;
- }
- mHandler.startSignIn(IdpResponse.from(e));
- }
- });
-
- mHandler.getOperation().observe(this, new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- startSaveCredentials(mHandler.getCurrentUser(), response, null);
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse res = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- finish(RESULT_CANCELED, new Intent().putExtra(ExtraConstants.IDP_RESPONSE, res));
- } else {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(e));
- }
- }
- });
-
- if (mHandler.getOperation().getValue() == null) {
- mProvider.startSignIn(getAuth(), this, provider);
- }
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- mHandler.onActivityResult(requestCode, resultCode, data);
- mProvider.onActivityResult(requestCode, resultCode, data);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/idp/WelcomeBackIdpPrompt.java b/auth/src/main/java/com/firebase/ui/auth/ui/idp/WelcomeBackIdpPrompt.java
deleted file mode 100644
index 0f3e2319c..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/idp/WelcomeBackIdpPrompt.java
+++ /dev/null
@@ -1,220 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.firebase.ui.auth.ui.idp;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-import android.view.View.OnClickListener;
-import android.widget.Button;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.AuthUI;
-import com.firebase.ui.auth.ErrorCodes;
-import com.firebase.ui.auth.FirebaseAuthAnonymousUpgradeException;
-import com.firebase.ui.auth.FirebaseUiException;
-import com.firebase.ui.auth.IdpResponse;
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.User;
-import com.firebase.ui.auth.data.remote.FacebookSignInHandler;
-import com.firebase.ui.auth.data.remote.GenericIdpAnonymousUpgradeLinkingHandler;
-import com.firebase.ui.auth.data.remote.GenericIdpSignInHandler;
-import com.firebase.ui.auth.data.remote.GoogleSignInHandler;
-import com.firebase.ui.auth.ui.AppCompatBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.data.ProviderUtils;
-import com.firebase.ui.auth.viewmodel.ProviderSignInBase;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.firebase.ui.auth.viewmodel.idp.LinkingSocialProviderResponseHandler;
-import com.google.android.gms.auth.api.Auth;
-import com.google.firebase.auth.FacebookAuthProvider;
-import com.google.firebase.auth.GoogleAuthProvider;
-
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class WelcomeBackIdpPrompt extends AppCompatBase {
- private ProviderSignInBase> mProvider;
-
- private Button mDoneButton;
- private ProgressBar mProgressBar;
- private TextView mPromptText;
-
- public static Intent createIntent(
- Context context, FlowParameters flowParams, User existingUser) {
- return createIntent(context, flowParams, existingUser, null);
- }
-
- public static Intent createIntent(
- Context context,
- FlowParameters flowParams,
- User existingUser,
- @Nullable IdpResponse requestedUserResponse) {
- return createBaseIntent(context, WelcomeBackIdpPrompt.class, flowParams)
- .putExtra(ExtraConstants.IDP_RESPONSE, requestedUserResponse)
- .putExtra(ExtraConstants.USER, existingUser);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.fui_welcome_back_idp_prompt_layout);
-
- mDoneButton = findViewById(R.id.welcome_back_idp_button);
- mProgressBar = findViewById(R.id.top_progress_bar);
- mPromptText = findViewById(R.id.welcome_back_idp_prompt);
-
- User existingUser = User.getUser(getIntent());
- IdpResponse requestedUserResponse = IdpResponse.fromResultIntent(getIntent());
- ViewModelProvider supplier = new ViewModelProvider(this);
-
- final LinkingSocialProviderResponseHandler handler =
- supplier.get(LinkingSocialProviderResponseHandler.class);
- handler.init(getFlowParams());
- if (requestedUserResponse != null) {
- handler.setRequestedSignInCredentialForEmail(
- ProviderUtils.getAuthCredential(requestedUserResponse),
- existingUser.getEmail());
- }
-
- final String providerId = existingUser.getProviderId();
- AuthUI.IdpConfig config =
- ProviderUtils.getConfigFromIdps(getFlowParams().providers, providerId);
- if (config == null) {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(new FirebaseUiException(
- ErrorCodes.DEVELOPER_ERROR,
- "Firebase login unsuccessful."
- + " Account linking failed due to provider not enabled by application: "
- + providerId)));
- return;
- }
-
-
- String providerName;
-
- String genericOAuthProviderId = config.getParams()
- .getString(ExtraConstants.GENERIC_OAUTH_PROVIDER_ID);
-
- boolean useEmulator = getAuthUI().isUseEmulator();
-
- switch (providerId) {
- case GoogleAuthProvider.PROVIDER_ID:
- if (useEmulator) {
- mProvider = supplier.get(GenericIdpAnonymousUpgradeLinkingHandler.class)
- .initWith(GenericIdpSignInHandler.getGenericGoogleConfig());
- } else {
- mProvider = supplier.get(GoogleSignInHandler.class).initWith(
- new GoogleSignInHandler.Params(config, existingUser.getEmail()));
- }
- providerName = getString(R.string.fui_idp_name_google);
- break;
- case FacebookAuthProvider.PROVIDER_ID:
- if (useEmulator) {
- mProvider = supplier.get(GenericIdpAnonymousUpgradeLinkingHandler.class)
- .initWith(GenericIdpSignInHandler.getGenericFacebookConfig());
- } else {
- mProvider = supplier.get(FacebookSignInHandler.class).initWith(config);
- }
- providerName = getString(R.string.fui_idp_name_facebook);
- break;
- default:
- if (TextUtils.equals(providerId, genericOAuthProviderId)) {
- mProvider = supplier.get(GenericIdpAnonymousUpgradeLinkingHandler.class)
- .initWith(config);
- providerName = config.getParams()
- .getString(ExtraConstants.GENERIC_OAUTH_PROVIDER_NAME);
- } else {
- throw new IllegalStateException("Invalid provider id: " + providerId);
- }
- }
-
- mProvider.getOperation().observe(this, new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- boolean isGenericIdp = getAuthUI().isUseEmulator()
- || !AuthUI.SOCIAL_PROVIDERS.contains(response.getProviderType());
-
- if (isGenericIdp
- && !response.hasCredentialForLinking()
- && !handler.hasCredentialForLinking()) {
- // Generic Idp does not return a credential - if this is not a linking flow,
- // the user is already signed in and we are done.
- finish(RESULT_OK, response.toIntent());
- return;
- }
- handler.startSignIn(response);
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- handler.startSignIn(IdpResponse.from(e));
- }
- });
-
- mPromptText.setText(getString(
- R.string.fui_welcome_back_idp_prompt,
- existingUser.getEmail(),
- providerName));
-
- mDoneButton.setOnClickListener(view -> mProvider.startSignIn(getAuth(), WelcomeBackIdpPrompt.this, providerId));
-
- handler.getOperation().observe(this, new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull IdpResponse response) {
- finish(RESULT_OK, response.toIntent());
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- if (e instanceof FirebaseAuthAnonymousUpgradeException) {
- IdpResponse response = ((FirebaseAuthAnonymousUpgradeException) e).getResponse();
- finish(ErrorCodes.ANONYMOUS_UPGRADE_MERGE_CONFLICT, response.toIntent());
- } else {
- finish(RESULT_CANCELED, IdpResponse.getErrorIntent(e));
- }
- }
- });
-
- TextView footerText = findViewById(R.id.email_footer_tos_and_pp_text);
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(this, getFlowParams(), footerText);
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- mProvider.onActivityResult(requestCode, resultCode, data);
- }
-
- @Override
- public void showProgress(int message) {
- mDoneButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mDoneButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt
new file mode 100644
index 000000000..7cf0c8ce5
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AnnotatedStringResource.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.method_picker
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.core.net.toUri
+
+@Composable
+internal fun AnnotatedStringResource(
+ context: Context,
+ modifier: Modifier = Modifier,
+ text: String,
+ vararg links: Pair,
+ inPreview: Boolean = false,
+ previewText: String? = null,
+) {
+ val template = if (inPreview && previewText != null) {
+ previewText
+ } else {
+ text
+ }
+
+ val annotated = buildAnnotatedString {
+ var currentIndex = 0
+
+ links.forEach { (label, url) ->
+ val start = template.indexOf(label, currentIndex).takeIf { it >= 0 } ?: return@forEach
+
+ append(template.substring(currentIndex, start))
+
+ withLink(
+ LinkAnnotation.Url(
+ url,
+ styles = TextLinkStyles(
+ style = SpanStyle(
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ )
+ )
+ ) {
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ context.startActivity(intent)
+ }
+ ) {
+ append(label)
+ }
+
+ currentIndex = start + label.length
+ }
+
+ if (currentIndex < template.length) {
+ append(template.substring(currentIndex))
+ }
+ }
+
+ Text(
+ modifier = modifier,
+ text = annotated,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
+ )
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
new file mode 100644
index 000000000..5c69b19bc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/method_picker/AuthMethodPicker.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.firebase.ui.auth.ui.method_picker
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.R
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUIAsset
+import com.firebase.ui.auth.ui.components.AuthProviderButton
+
+/**
+ * Renders the provider selection screen.
+ *
+ * **Example usage:**
+ * ```kotlin
+ * AuthMethodPicker(
+ * providers = listOf(
+ * AuthProvider.Google(),
+ * AuthProvider.Email(),
+ * ),
+ * onProviderSelected = { provider -> /* ... */ }
+ * )
+ * ```
+ *
+ * @param modifier A modifier for the screen layout.
+ * @param providers The list of providers to display.
+ * @param logo An optional logo to display.
+ * @param onProviderSelected A callback when a provider is selected.
+ * @param customLayout An optional custom layout composable for the provider buttons.
+ * @param termsOfServiceUrl The URL for the Terms of Service.
+ * @param privacyPolicyUrl The URL for the Privacy Policy.
+ *
+ * @since 10.0.0
+ */
+@Composable
+fun AuthMethodPicker(
+ modifier: Modifier = Modifier,
+ providers: List,
+ logo: AuthUIAsset? = null,
+ onProviderSelected: (AuthProvider) -> Unit,
+ customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null,
+ termsOfServiceUrl: String? = null,
+ privacyPolicyUrl: String? = null,
+) {
+ val context = LocalContext.current
+ val inPreview = LocalInspectionMode.current
+ val stringProvider = LocalAuthUIStringProvider.current
+
+ Column(
+ modifier = modifier
+ ) {
+ logo?.let {
+ Image(
+ modifier = Modifier
+ .weight(0.4f)
+ .align(Alignment.CenterHorizontally),
+ painter = it.painter,
+ contentDescription = if (inPreview) ""
+ else stringResource(R.string.fui_auth_method_picker_logo)
+ )
+ }
+ if (customLayout != null) {
+ customLayout(providers, onProviderSelected)
+ } else {
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f),
+ ) {
+ val paddingWidth = maxWidth.value * 0.23
+ LazyColumn(
+ modifier = Modifier
+ .padding(horizontal = paddingWidth.dp)
+ .testTag("AuthMethodPicker LazyColumn"),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ itemsIndexed(providers) { index, provider ->
+ Box(
+ modifier = Modifier
+ .padding(bottom = if (index < providers.lastIndex) 16.dp else 0.dp)
+ ) {
+ AuthProviderButton(
+ modifier = Modifier
+ .fillMaxWidth(),
+ onClick = {
+ onProviderSelected(provider)
+ },
+ provider = provider,
+ stringProvider = LocalAuthUIStringProvider.current
+ )
+ }
+ }
+ }
+ }
+ }
+ AnnotatedStringResource(
+ modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
+ context = context,
+ inPreview = inPreview,
+ previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
+ text = stringProvider.tosAndPrivacyPolicy(
+ termsOfServiceLabel = stringProvider.termsOfService,
+ privacyPolicyLabel = stringProvider.privacyPolicy
+ ),
+ links = arrayOf(
+ stringProvider.termsOfService to (termsOfServiceUrl ?: ""),
+ stringProvider.privacyPolicy to (privacyPolicyUrl ?: "")
+ )
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PreviewAuthMethodPicker() {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ AuthMethodPicker(
+ providers = listOf(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null,
+ ),
+ AuthProvider.Google(
+ scopes = emptyList(),
+ serverClientId = null
+ ),
+ AuthProvider.Facebook(),
+ AuthProvider.Twitter(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Github(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Microsoft(
+ tenant = null,
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Yahoo(
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Apple(
+ locale = null,
+ customParameters = emptyMap()
+ ),
+ AuthProvider.Anonymous,
+ ),
+ logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp),
+ onProviderSelected = { provider ->
+
+ },
+ termsOfServiceUrl = "https://example.com/terms",
+ privacyPolicyUrl = "https://example.com/privacy"
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java b/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java
deleted file mode 100644
index 4b7f7584f..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the
- * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
- * express or implied. See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Activities which implement the AuthUI authentication flow.
- */
-package com.firebase.ui.auth.ui;
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneHandler.java b/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneHandler.java
deleted file mode 100644
index c10e5067a..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneHandler.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package com.firebase.ui.auth.ui.phone;
-
-import android.app.Activity;
-import android.app.Application;
-import android.content.Intent;
-import android.content.IntentSender;
-import android.util.Log;
-
-import com.firebase.ui.auth.data.model.PendingIntentRequiredException;
-import com.firebase.ui.auth.data.model.PhoneNumber;
-import com.firebase.ui.auth.data.model.Resource;
-import com.firebase.ui.auth.util.data.PhoneNumberUtils;
-import com.firebase.ui.auth.viewmodel.AuthViewModelBase;
-import com.firebase.ui.auth.viewmodel.RequestCodes;
-import com.google.android.gms.auth.api.identity.GetPhoneNumberHintIntentRequest;
-import com.google.android.gms.auth.api.identity.Identity;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class CheckPhoneHandler extends AuthViewModelBase {
-
- private static final String TAG = "CheckPhoneHandler";
-
- public CheckPhoneHandler(Application application) {
- super(application);
- }
-
- /**
- * Initiates the Phone Number Hint flow using the new API.
- *
- *
This method creates a GetPhoneNumberHintIntentRequest and calls
- * Identity.getSignInClient(activity).getPhoneNumberHintIntent(request) to retrieve an
- * IntentSender. The IntentSender is then wrapped in a PendingIntentRequiredException so that
- * the caller can launch the hint flow.
- *
- *
Note: Update your PendingIntentRequiredException to accept an IntentSender
- * rather than a PendingIntent.
- *
- * @param activity The activity used to retrieve the Phone Number Hint IntentSender.
- */
- public void fetchCredential(final Activity activity) {
- GetPhoneNumberHintIntentRequest request = GetPhoneNumberHintIntentRequest.builder().build();
- Identity.getSignInClient(activity)
- .getPhoneNumberHintIntent(request)
- .addOnSuccessListener(result -> {
- try {
- // The new API returns an IntentSender.
- IntentSender intentSender = result.getIntentSender();
- // Update your exception to accept an IntentSender.
- setResult(Resource.forFailure(new PendingIntentRequiredException(intentSender, RequestCodes.CRED_HINT)));
- } catch (Exception e) {
- Log.e(TAG, "Launching the IntentSender failed", e);
- setResult(Resource.forFailure(e));
- }
- })
- .addOnFailureListener(e -> {
- Log.e(TAG, "Phone Number Hint failed", e);
- setResult(Resource.forFailure(e));
- });
- }
-
- /**
- * Handles the result from the Phone Number Hint flow.
- *
- *
Call this method from your Activity's onActivityResult. It extracts the phone number from the
- * returned Intent and formats it.
- *
- * @param activity The activity used to process the returned Intent.
- * @param requestCode The request code (should match RequestCodes.CRED_HINT).
- * @param resultCode The result code from the hint flow.
- * @param data The Intent data returned from the hint flow.
- */
- public void onActivityResult(Activity activity, int requestCode, int resultCode, @Nullable Intent data) {
- if (requestCode != RequestCodes.CRED_HINT || resultCode != Activity.RESULT_OK) {
- return;
- }
- try {
- String phoneNumber = Identity.getSignInClient(activity).getPhoneNumberFromIntent(data);
- String formattedPhone = PhoneNumberUtils.formatUsingCurrentCountry(phoneNumber, getApplication());
- if (formattedPhone != null) {
- setResult(Resource.forSuccess(PhoneNumberUtils.getPhoneNumber(formattedPhone)));
- } else {
- setResult(Resource.forFailure(new Exception("Failed to format phone number")));
- }
- } catch (Exception e) {
- Log.e(TAG, "Phone Number Hint failed", e);
- setResult(Resource.forFailure(e));
- }
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneNumberFragment.java b/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneNumberFragment.java
deleted file mode 100644
index c011f1a3e..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CheckPhoneNumberFragment.java
+++ /dev/null
@@ -1,240 +0,0 @@
-package com.firebase.ui.auth.ui.phone;
-
-import android.content.Intent;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.FlowParameters;
-import com.firebase.ui.auth.data.model.PhoneNumber;
-import com.firebase.ui.auth.ui.FragmentBase;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PhoneNumberUtils;
-import com.firebase.ui.auth.util.data.PrivacyDisclosureUtils;
-import com.firebase.ui.auth.util.ui.ImeHelper;
-import com.firebase.ui.auth.viewmodel.ResourceObserver;
-import com.google.android.material.textfield.TextInputLayout;
-
-import java.util.Locale;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.lifecycle.ViewModelProvider;
-
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-public class CheckPhoneNumberFragment extends FragmentBase implements View.OnClickListener {
- public static final String TAG = "VerifyPhoneFragment";
-
- private PhoneNumberVerificationHandler mVerificationHandler;
- private CheckPhoneHandler mCheckPhoneHandler;
- private boolean mCalled;
-
- private ProgressBar mProgressBar;
- private Button mSubmitButton;
- private CountryListSpinner mCountryListSpinner;
- private View mCountryListAnchor;
- private TextInputLayout mPhoneInputLayout;
- private EditText mPhoneEditText;
- private TextView mSmsTermsText;
- private TextView mFooterText;
-
- public static CheckPhoneNumberFragment newInstance(Bundle params) {
- CheckPhoneNumberFragment fragment = new CheckPhoneNumberFragment();
- Bundle args = new Bundle();
- args.putBundle(ExtraConstants.PARAMS, params);
- fragment.setArguments(args);
- return fragment;
- }
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mVerificationHandler = new ViewModelProvider(requireActivity())
- .get(PhoneNumberVerificationHandler.class);
- mCheckPhoneHandler = new ViewModelProvider(this)
- .get(CheckPhoneHandler.class);
- }
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater,
- @Nullable ViewGroup container,
- @Nullable Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fui_phone_layout, container, false);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- mProgressBar = view.findViewById(R.id.top_progress_bar);
- mSubmitButton = view.findViewById(R.id.send_code);
- mCountryListSpinner = view.findViewById(R.id.country_list);
- mCountryListAnchor = view.findViewById(R.id.country_list_popup_anchor);
- mPhoneInputLayout = view.findViewById(R.id.phone_layout);
- mPhoneEditText = view.findViewById(R.id.phone_number);
- mSmsTermsText = view.findViewById(R.id.send_sms_tos);
- mFooterText = view.findViewById(R.id.email_footer_tos_and_pp_text);
-
- mSmsTermsText.setText(getString(R.string.fui_sms_terms_of_service,
- getString(R.string.fui_verify_phone_number)));
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- mPhoneEditText.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
- }
- requireActivity().setTitle(getString(R.string.fui_verify_phone_number_title));
-
- ImeHelper.setImeOnDoneListener(mPhoneEditText, this::onNext);
- mSubmitButton.setOnClickListener(this);
-
- setupPrivacyDisclosures();
- setupCountrySpinner();
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- mCheckPhoneHandler.getOperation().observe(getViewLifecycleOwner(), new ResourceObserver(this) {
- @Override
- protected void onSuccess(@NonNull PhoneNumber number) {
- start(number);
- }
-
- @Override
- protected void onFailure(@NonNull Exception e) {
- // Let the user enter their data if hint retrieval fails
- }
- });
-
- if (savedInstanceState != null || mCalled) {
- return;
- }
- // Fragment back stacks can cause state retention so we rely on an instance field.
- mCalled = true;
-
- // Set default country or prompt for phone number using the Phone Number Hint flow.
- setDefaultCountryForSpinner();
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
- // Pass the activity instance to the handler
- mCheckPhoneHandler.onActivityResult(requireActivity(), requestCode, resultCode, data);
- }
-
- @Override
- public void onClick(View v) {
- onNext();
- }
-
- private void start(PhoneNumber number) {
- if (!PhoneNumber.isValid(number)) {
- mPhoneInputLayout.setError(getString(R.string.fui_invalid_phone_number));
- return;
- }
- mPhoneEditText.setText(number.getPhoneNumber());
- mPhoneEditText.setSelection(number.getPhoneNumber().length());
-
- String iso = number.getCountryIso();
-
- if (PhoneNumber.isCountryValid(number) && mCountryListSpinner.isValidIso(iso)) {
- setCountryCode(number);
- onNext();
- }
- }
-
- private void onNext() {
- String phoneNumber = getPseudoValidPhoneNumber();
- if (phoneNumber == null) {
- mPhoneInputLayout.setError(getString(R.string.fui_invalid_phone_number));
- } else {
- mVerificationHandler.verifyPhoneNumber(requireActivity(), phoneNumber, false);
- }
- }
-
- @Nullable
- private String getPseudoValidPhoneNumber() {
- String everythingElse = mPhoneEditText.getText().toString();
- if (TextUtils.isEmpty(everythingElse)) {
- return null;
- }
- return PhoneNumberUtils.format(
- everythingElse, mCountryListSpinner.getSelectedCountryInfo());
- }
-
- private void setupPrivacyDisclosures() {
- FlowParameters params = getFlowParams();
- boolean termsAndPrivacyUrlsProvided = params.isTermsOfServiceUrlProvided()
- && params.isPrivacyPolicyUrlProvided();
-
- if (!params.shouldShowProviderChoice() && termsAndPrivacyUrlsProvided) {
- PrivacyDisclosureUtils.setupTermsOfServiceAndPrivacyPolicySmsText(requireContext(),
- params,
- mSmsTermsText);
- } else {
- PrivacyDisclosureUtils.setupTermsOfServiceFooter(requireContext(),
- params,
- mFooterText);
- String verifyText = getString(R.string.fui_verify_phone_number);
- mSmsTermsText.setText(getString(R.string.fui_sms_terms_of_service, verifyText));
- }
- }
-
- private void setCountryCode(PhoneNumber number) {
- mCountryListSpinner.setSelectedForCountry(
- new Locale("", number.getCountryIso()), number.getCountryCode());
- }
-
- private void setupCountrySpinner() {
- Bundle params = getArguments().getBundle(ExtraConstants.PARAMS);
- mCountryListSpinner.init(params, mCountryListAnchor);
- // Clear error when spinner is clicked
- mCountryListSpinner.setOnClickListener(v -> mPhoneInputLayout.setError(null));
- }
-
- private void setDefaultCountryForSpinner() {
- // Check for phone number defaults
- Bundle params = getArguments().getBundle(ExtraConstants.PARAMS);
- String phone = null;
- String countryIso = null;
- String nationalNumber = null;
- if (params != null) {
- phone = params.getString(ExtraConstants.PHONE);
- countryIso = params.getString(ExtraConstants.COUNTRY_ISO);
- nationalNumber = params.getString(ExtraConstants.NATIONAL_NUMBER);
- }
-
- // If phone is provided in full, use it. Otherwise, parse ISO and national number or prompt for a phone hint.
- if (!TextUtils.isEmpty(phone)) {
- start(PhoneNumberUtils.getPhoneNumber(phone));
- } else if (!TextUtils.isEmpty(countryIso) && !TextUtils.isEmpty(nationalNumber)) {
- start(PhoneNumberUtils.getPhoneNumber(countryIso, nationalNumber));
- } else if (!TextUtils.isEmpty(countryIso)) {
- setCountryCode(new PhoneNumber(
- "",
- countryIso,
- String.valueOf(PhoneNumberUtils.getCountryCode(countryIso))));
- } else if (getFlowParams().enableCredentials) {
- // Launch phone number hint flow using the new API
- mCheckPhoneHandler.fetchCredential(requireActivity());
- }
- }
-
- @Override
- public void showProgress(int message) {
- mSubmitButton.setEnabled(false);
- mProgressBar.setVisibility(View.VISIBLE);
- }
-
- @Override
- public void hideProgress() {
- mSubmitButton.setEnabled(true);
- mProgressBar.setVisibility(View.INVISIBLE);
- }
-}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CountryListSpinner.java b/auth/src/main/java/com/firebase/ui/auth/ui/phone/CountryListSpinner.java
deleted file mode 100644
index d61504564..000000000
--- a/auth/src/main/java/com/firebase/ui/auth/ui/phone/CountryListSpinner.java
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- * Copyright (C) 2015 Twitter, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- * Modifications copyright (C) 2017 Google Inc
- */
-package com.firebase.ui.auth.ui.phone;
-
-import android.content.Context;
-import android.graphics.Rect;
-import android.os.Bundle;
-import android.os.Parcelable;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.view.View;
-import android.view.inputmethod.EditorInfo;
-import android.view.inputmethod.InputMethodManager;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-
-import com.firebase.ui.auth.R;
-import com.firebase.ui.auth.data.model.CountryInfo;
-import com.firebase.ui.auth.util.ExtraConstants;
-import com.firebase.ui.auth.util.data.PhoneNumberUtils;
-import com.google.android.material.textfield.TextInputEditText;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.widget.ListPopupWindow;
-
-public final class CountryListSpinner extends TextInputEditText implements View.OnClickListener {
-
- private static final String KEY_SUPER_STATE = "KEY_SUPER_STATE";
- private static final String KEY_COUNTRY_INFO = "KEY_COUNTRY_INFO";
-
- private final ArrayAdapter mCountryListAdapter;
- private View.OnClickListener mListener;
- private CountryInfo mSelectedCountryInfo;
-
- private ListPopupWindow mListPopupWindow;
-
- private Set