diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml index 347c621c2..3446e5dbe 100644 --- a/auth/src/main/AndroidManifest.xml +++ b/auth/src/main/AndroidManifest.xml @@ -122,6 +122,7 @@ + + * // 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/compose/FirebaseAuthActivity.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt new file mode 100644 index 000000000..fac4e0351 --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthActivity.kt @@ -0,0 +1,200 @@ +/* + * 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.compose + +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.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen +import com.google.firebase.auth.FirebaseUser +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) + + // 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() + + // 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, + 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.compose.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.compose.USER_ID" + + /** + * Intent extra key for isNewUser flag on successful sign-in. + */ + const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.compose.IS_NEW_USER" + + /** + * Intent extra key for [AuthException] on error. + */ + const val EXTRA_ERROR = "com.firebase.ui.auth.compose.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/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index 6146f10f7..4c1c2a1c2 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -16,6 +16,7 @@ package com.firebase.ui.auth.compose import android.content.Context import androidx.annotation.RestrictTo +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth.AuthStateListener @@ -112,6 +113,81 @@ class FirebaseAuthUI private constructor( */ fun getCurrentUser(): FirebaseUser? = auth.currentUser + /** + * 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. * diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt new file mode 100644 index 000000000..2c80a8e0e --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/AuthFlowControllerTest.kt @@ -0,0 +1,478 @@ +/* + * 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.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [AuthFlowController] covering lifecycle management, + * intent creation, state observation, and resource disposal. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class AuthFlowControllerTest { + + private lateinit var applicationContext: Context + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + @Mock + private lateinit var mockActivity: Activity + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + configuration = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + ) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { app -> + try { + app.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + } + + // ============================================================================================= + // Controller Creation Tests + // ============================================================================================= + + @Test + fun `createAuthFlow() returns new controller instance`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller).isNotNull() + assertThat(controller.isDisposed()).isFalse() + } + + @Test + fun `createAuthFlow() returns different instances each time`() { + val controller1 = authUI.createAuthFlow(configuration) + val controller2 = authUI.createAuthFlow(configuration) + + // Each call should return a new controller instance + assertThat(controller1).isNotEqualTo(controller2) + } + + // ============================================================================================= + // Intent Creation Tests + // ============================================================================================= + + @Test + fun `createIntent() returns valid Intent`() { + val controller = authUI.createAuthFlow(configuration) + val intent = controller.createIntent(applicationContext) + + assertThat(intent).isNotNull() + assertThat(intent.component?.className).contains("FirebaseAuthActivity") + } + + @Test + fun `createIntent() contains configuration key`() { + val controller = authUI.createAuthFlow(configuration) + val intent = controller.createIntent(applicationContext) + + // Intent should contain a configuration key extra + val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + assertThat(configKey).isNotNull() + assertThat(configKey).isNotEmpty() + } + + @Test + fun `createIntent() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.createIntent(applicationContext) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Auth State Flow Tests + // ============================================================================================= + + @Test + fun `authStateFlow observes state changes from FirebaseAuthUI`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Collect initial state + val initialState = controller.authStateFlow.first() + assertThat(initialState).isInstanceOf(AuthState.Idle::class.java) + } + + @Test + fun `authStateFlow emits Loading state when updated`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Update state + authUI.updateAuthState(AuthState.Loading("Testing")) + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Collect first state after update + val state = controller.authStateFlow.first() + + // Should be Loading state + assertThat(state).isInstanceOf(AuthState.Loading::class.java) + assertThat((state as AuthState.Loading).message).isEqualTo("Testing") + } + + @Test + fun `authStateFlow throws IllegalStateException when disposed`() = runTest { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.authStateFlow.first() + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Cancel Tests + // ============================================================================================= + + @Test + fun `cancel() updates state to Cancelled`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Cancel the flow + controller.cancel() + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Collect first state after cancel + val state = controller.authStateFlow.first() + + // Should be Cancelled state + assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + } + + @Test + fun `cancel() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + try { + controller.cancel() + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + // ============================================================================================= + // Dispose Tests + // ============================================================================================= + + @Test + fun `dispose() marks controller as disposed`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller.isDisposed()).isFalse() + + controller.dispose() + + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `dispose() can be called multiple times safely`() { + val controller = authUI.createAuthFlow(configuration) + + controller.dispose() + controller.dispose() // Should not throw + + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `dispose() prevents further operations`() { + val controller = authUI.createAuthFlow(configuration) + controller.dispose() + + // All operations should throw IllegalStateException + try { + controller.createIntent(applicationContext) + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + + try { + controller.cancel() + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + @Test + fun `isDisposed() returns correct state`() { + val controller = authUI.createAuthFlow(configuration) + + assertThat(controller.isDisposed()).isFalse() + + controller.dispose() + + assertThat(controller.isDisposed()).isTrue() + } + + // ============================================================================================= + // Deprecated Start Method Tests + // ============================================================================================= + + @Suppress("DEPRECATION") + @Test + fun `start() launches activity with correct intent`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.start(activity, AuthFlowController.RC_SIGN_IN) + + // Verify an activity was started + val shadowActivity = org.robolectric.Shadows.shadowOf(activity) + val startedIntent = shadowActivity.nextStartedActivity + + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.component?.className).contains("FirebaseAuthActivity") + } + + @Suppress("DEPRECATION") + @Test + fun `start() throws IllegalStateException when disposed`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.dispose() + + try { + controller.start(activity, AuthFlowController.RC_SIGN_IN) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalStateException) { + assertThat(e.message).contains("disposed") + } + } + + @Suppress("DEPRECATION") + @Test + fun `start() uses default request code when not specified`() { + val controller = authUI.createAuthFlow(configuration) + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + + controller.start(activity) + + // Verify an activity was started (request code verification is internal) + val shadowActivity = org.robolectric.Shadows.shadowOf(activity) + val startedIntent = shadowActivity.nextStartedActivity + + assertThat(startedIntent).isNotNull() + } + + // ============================================================================================= + // Thread Safety Tests + // ============================================================================================= + + @Test + fun `dispose() is thread-safe`() { + val controller = authUI.createAuthFlow(configuration) + + val threads = List(10) { + Thread { + controller.dispose() + } + } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for all threads to complete + threads.forEach { it.join() } + + // Controller should be disposed exactly once + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `multiple controllers can be created and disposed independently`() { + val controller1 = authUI.createAuthFlow(configuration) + val controller2 = authUI.createAuthFlow(configuration) + val controller3 = authUI.createAuthFlow(configuration) + + controller1.dispose() + + // Other controllers should still be usable + assertThat(controller1.isDisposed()).isTrue() + assertThat(controller2.isDisposed()).isFalse() + assertThat(controller3.isDisposed()).isFalse() + + // Can still create intents with non-disposed controllers + val intent2 = controller2.createIntent(applicationContext) + val intent3 = controller3.createIntent(applicationContext) + + assertThat(intent2).isNotNull() + assertThat(intent3).isNotNull() + + controller2.dispose() + controller3.dispose() + + assertThat(controller2.isDisposed()).isTrue() + assertThat(controller3.isDisposed()).isTrue() + } + + // ============================================================================================= + // Configuration Tests + // ============================================================================================= + + @Test + fun `controller preserves configuration for intent creation`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos", + privacyPolicyUrl = "https://example.com/privacy" + ) + + val controller = authUI.createAuthFlow(customConfig) + val intent = controller.createIntent(applicationContext) + + // Intent should be created successfully with custom config + assertThat(intent).isNotNull() + assertThat(intent.component?.className).contains("FirebaseAuthActivity") + } + + // ============================================================================================= + // Lifecycle Tests + // ============================================================================================= + + @Test + fun `typical lifecycle - create, start, cancel, dispose`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Create intent + val intent = controller.createIntent(applicationContext) + assertThat(intent).isNotNull() + + // Cancel flow + controller.cancel() + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Verify cancelled state + val state = controller.authStateFlow.first() + assertThat(state).isInstanceOf(AuthState.Cancelled::class.java) + + // Dispose + controller.dispose() + assertThat(controller.isDisposed()).isTrue() + } + + @Test + fun `typical lifecycle - create, start, observe, dispose`() = runTest { + val controller = authUI.createAuthFlow(configuration) + + // Create intent + val intent = controller.createIntent(applicationContext) + assertThat(intent).isNotNull() + + // Update state + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + // Advance test scheduler to process all pending coroutines + testScheduler.advanceUntilIdle() + + // Observe state change + val state = controller.authStateFlow.first() + assertThat(state).isInstanceOf(AuthState.Loading::class.java) + + // Dispose + controller.dispose() + assertThat(controller.isDisposed()).isTrue() + } +} diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt new file mode 100644 index 000000000..8b21965fa --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthActivityTest.kt @@ -0,0 +1,539 @@ +/* + * 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.compose + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import android.os.Looper +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +/** + * Unit tests for [FirebaseAuthActivity] covering activity lifecycle, + * intent handling, state observation, and result handling. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [34]) +class FirebaseAuthActivityTest { + + private lateinit var applicationContext: Context + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + @Mock + private lateinit var mockFirebaseUser: FirebaseUser + + @Mock + private lateinit var mockMultiFactorResolver: MultiFactorResolver + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + applicationContext = ApplicationProvider.getApplicationContext() + + // Clear any existing Firebase apps + FirebaseApp.getApps(applicationContext).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + FirebaseApp.initializeApp( + applicationContext, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + authUI = FirebaseAuthUI.getInstance() + authUI.auth.useEmulator("127.0.0.1", 9099) + + configuration = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + ) + + // Reset auth state before each test + authUI.updateAuthState(AuthState.Idle) + } + + @After + fun tearDown() { + FirebaseAuthUI.clearInstanceCache() + FirebaseApp.getApps(applicationContext).forEach { app -> + try { + app.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + } + } + + // ============================================================================================= + // Activity Launch Tests + // ============================================================================================= + + @Test + fun `activity launches successfully with valid configuration`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + assertThat(activity).isNotNull() + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity finishes immediately when configuration is missing`() { + // Create intent without configuration + val intent = Intent(applicationContext, FirebaseAuthActivity::class.java) + + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + val activity = controller.create().get() + + // Activity should finish immediately + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + } + + // ============================================================================================= + // Configuration Extraction Tests + // ============================================================================================= + + @Test + fun `createIntent() stores configuration in cache`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + + // Intent should contain configuration key + val configKey = intent.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + assertThat(configKey).isNotNull() + assertThat(configKey).isNotEmpty() + } + + @Test + fun `activity extracts configuration from intent on onCreate`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos" + ) + + val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().get() + + // Activity should have been created successfully (not finished) + assertThat(activity.isFinishing).isFalse() + } + + // ============================================================================================= + // Auth State Success Tests + // ============================================================================================= + + @Test + fun `activity finishes with RESULT_OK on Success state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Mock user + `when`(mockFirebaseUser.uid).thenReturn("test-user-id") + + // Update to Success state + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = true + )) + + // Process pending tasks on main looper + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_OK + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) + + // Result intent should contain user data + val resultIntent = shadowActivity.resultIntent + assertThat(resultIntent).isNotNull() + assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)) + .isEqualTo("test-user-id") + assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, false)) + .isTrue() + } + + @Test + fun `activity returns correct user data on success`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Mock user with specific data + `when`(mockFirebaseUser.uid).thenReturn("user-123") + + // Update to Success state with isNewUser = false + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + )) + + shadowOf(Looper.getMainLooper()).idle() + + val shadowActivity = shadowOf(activity) + val resultIntent = shadowActivity.resultIntent + + assertThat(resultIntent.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID)) + .isEqualTo("user-123") + assertThat(resultIntent.getBooleanExtra(FirebaseAuthActivity.EXTRA_IS_NEW_USER, true)) + .isFalse() + } + + // ============================================================================================= + // Auth State Cancelled Tests + // ============================================================================================= + + @Test + fun `activity finishes with RESULT_CANCELED on Cancelled state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Cancelled state + authUI.updateAuthState(AuthState.Cancelled) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish + assertThat(activity.isFinishing).isTrue() + + // Result should be RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + } + + // ============================================================================================= + // Auth State Error Tests + // ============================================================================================= + + @Test + fun `activity sets RESULT_CANCELED on Error state but does not finish`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Error state + val exception = AuthException.UnknownException("Test error") + authUI.updateAuthState(AuthState.Error(exception)) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish (to let user see error and retry) + assertThat(activity.isFinishing).isFalse() + + // Result should be set to RESULT_CANCELED + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_CANCELED) + + // Result intent should contain error + val resultIntent = shadowActivity.resultIntent + assertThat(resultIntent).isNotNull() + assertThat(resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR)) + .isInstanceOf(AuthException::class.java) + } + + @Test + fun `activity includes error in result intent on Error state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Error state with specific exception + val exception = AuthException.InvalidCredentialsException( + message = "Invalid credentials" + ) + authUI.updateAuthState(AuthState.Error(exception)) + + shadowOf(Looper.getMainLooper()).idle() + + val shadowActivity = shadowOf(activity) + val resultIntent = shadowActivity.resultIntent + val resultError = resultIntent.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR) as AuthException + + assertThat(resultError).isInstanceOf(AuthException.InvalidCredentialsException::class.java) + assertThat(resultError.message).isEqualTo("Invalid credentials") + } + + // ============================================================================================= + // Activity Lifecycle Tests + // ============================================================================================= + + @Test + fun `activity resets auth state to Idle on destroy when not finishing`() { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Set some state + authUI.updateAuthState(AuthState.Loading("Testing")) + + // Destroy without finishing + controller.pause().stop().destroy() + + // Note: This test verifies the lifecycle hook is in place + // The actual state reset behavior depends on the isFinishing flag + } + + @Test + fun `activity handles rapid state transitions`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Simulate rapid state changes + authUI.updateAuthState(AuthState.Loading("Loading...")) + shadowOf(Looper.getMainLooper()).idle() + authUI.updateAuthState(AuthState.Loading("Signing in...")) + shadowOf(Looper.getMainLooper()).idle() + `when`(mockFirebaseUser.uid).thenReturn("test-user") + authUI.updateAuthState(AuthState.Success( + result = null, + user = mockFirebaseUser, + isNewUser = false + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should finish on final Success state + assertThat(activity.isFinishing).isTrue() + + val shadowActivity = shadowOf(activity) + assertThat(shadowActivity.resultCode).isEqualTo(Activity.RESULT_OK) + } + + // ============================================================================================= + // Intent Extras Constants Tests + // ============================================================================================= + + @Test + fun `activity exposes correct intent extra constants`() { + assertThat(FirebaseAuthActivity.EXTRA_USER_ID) + .isEqualTo("com.firebase.ui.auth.compose.USER_ID") + assertThat(FirebaseAuthActivity.EXTRA_IS_NEW_USER) + .isEqualTo("com.firebase.ui.auth.compose.IS_NEW_USER") + assertThat(FirebaseAuthActivity.EXTRA_ERROR) + .isEqualTo("com.firebase.ui.auth.compose.ERROR") + } + + // ============================================================================================= + // Configuration Cache Tests + // ============================================================================================= + + @Test + fun `configuration is removed from cache after onCreate`() { + val intent1 = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val configKey1 = intent1.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + + assertThat(configKey1).isNotNull() + + // Create activity - this should consume the configuration from cache + val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1) + controller1.create().get() + + // Create another intent + val intent2 = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val configKey2 = intent2.getStringExtra("com.firebase.ui.auth.compose.CONFIGURATION_KEY") + + // Should be a different key + assertThat(configKey2).isNotEqualTo(configKey1) + } + + @Test + fun `multiple activities can be launched with different configurations`() { + val config1 = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ), + tosUrl = "https://example.com/tos1" + ) + + val config2 = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ), + tosUrl = "https://example.com/tos2" + ) + + val intent1 = FirebaseAuthActivity.createIntent(applicationContext, config1) + val intent2 = FirebaseAuthActivity.createIntent(applicationContext, config2) + + // Both activities should launch successfully + val controller1 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent1) + val activity1 = controller1.create().get() + assertThat(activity1.isFinishing).isFalse() + + val controller2 = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent2) + val activity2 = controller2.create().get() + assertThat(activity2.isFinishing).isFalse() + } + + // ============================================================================================= + // Other State Tests + // ============================================================================================= + + @Test + fun `activity continues showing UI on Loading state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to Loading state + authUI.updateAuthState(AuthState.Loading("Signing in...")) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on Loading state + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity continues showing UI on RequiresMfa state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to RequiresMfa state (mocked resolver) + authUI.updateAuthState(AuthState.RequiresMfa( + resolver = mockMultiFactorResolver, + hint = "Enter verification code" + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on RequiresMfa state + assertThat(activity.isFinishing).isFalse() + } + + @Test + fun `activity continues showing UI on RequiresEmailVerification state`() = runTest { + val intent = FirebaseAuthActivity.createIntent(applicationContext, configuration) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().start().resume().get() + + // Update to RequiresEmailVerification state + `when`(mockFirebaseUser.email).thenReturn("test@example.com") + authUI.updateAuthState(AuthState.RequiresEmailVerification( + user = mockFirebaseUser, + email = "test@example.com" + )) + + shadowOf(Looper.getMainLooper()).idle() + + // Activity should NOT finish on RequiresEmailVerification state + assertThat(activity.isFinishing).isFalse() + } + + // ============================================================================================= + // Theme Tests + // ============================================================================================= + + @Test + fun `activity applies theme from configuration`() { + val customConfig = AuthUIConfiguration( + context = applicationContext, + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ), + theme = com.firebase.ui.auth.compose.configuration.theme.AuthUITheme.Default + ) + + val intent = FirebaseAuthActivity.createIntent(applicationContext, customConfig) + val controller = Robolectric.buildActivity(FirebaseAuthActivity::class.java, intent) + + val activity = controller.create().get() + + // Activity should launch successfully with custom theme + assertThat(activity.isFinishing).isFalse() + } +} diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml index 83e2dd1a5..3b452f820 100644 --- a/composeapp/src/main/AndroidManifest.xml +++ b/composeapp/src/main/AndroidManifest.xml @@ -38,6 +38,18 @@ tools:ignore="AppLinksAutoVerify,AppLinkUrlError" /> + + + + \ No newline at end of file diff --git a/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt new file mode 100644 index 000000000..dfdcb0cab --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/AuthFlowControllerDemoActivity.kt @@ -0,0 +1,337 @@ +package com.firebase.composeapp + +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.compose.AuthFlowController +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthActivity +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.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://temp-test-aa342.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "com.firebase.composeapp", + 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( + applicationId = "792556260059222" + ) + ), + 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/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt new file mode 100644 index 000000000..0fd123c85 --- /dev/null +++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt @@ -0,0 +1,210 @@ +package com.firebase.composeapp + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.Composable +import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState +import com.firebase.ui.auth.compose.FirebaseAuthUI +import com.firebase.ui.auth.compose.configuration.PasswordRule +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity +import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.compose.ui.screens.AuthSuccessUiContext +import com.google.firebase.auth.actionCodeSettings + +class HighLevelApiDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val authUI = FirebaseAuthUI.getInstance() + val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK) + + val configuration = authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkForceSameDeviceEnabled = true, + isEmailLinkSignInEnabled = false, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://temp-test-aa342.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "com.firebase.composeapp", + 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( + applicationId = "792556260059222" + ) + ) + } + tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1" + privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" + } + + 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)) + } + + 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/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 4c0bbf1ee..bc138fdb8 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -1,39 +1,24 @@ package com.firebase.composeapp +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.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier +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 androidx.compose.runtime.Composable -import com.firebase.ui.auth.compose.AuthException -import com.firebase.ui.auth.compose.AuthState import com.firebase.ui.auth.compose.FirebaseAuthUI -import com.firebase.ui.auth.compose.configuration.PasswordRule -import com.firebase.ui.auth.compose.configuration.authUIConfiguration -import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme -import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity -import com.firebase.ui.auth.compose.ui.screens.FirebaseAuthScreen -import com.firebase.ui.auth.compose.ui.screens.AuthSuccessUiContext import com.google.firebase.FirebaseApp -import com.google.firebase.auth.actionCodeSettings +/** + * Main launcher activity that allows users to choose between different + * authentication API demonstrations. + */ class MainActivity : ComponentActivity() { companion object { private const val USE_AUTH_EMULATOR = true @@ -45,6 +30,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Initialize Firebase and configure emulator if needed FirebaseApp.initializeApp(applicationContext) val authUI = FirebaseAuthUI.getInstance() @@ -52,198 +38,149 @@ class MainActivity : ComponentActivity() { authUI.auth.useEmulator(AUTH_EMULATOR_HOST, AUTH_EMULATOR_PORT) } - val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK) - setContent { - val configuration = authUIConfiguration { - context = applicationContext - providers { - provider(AuthProvider.Anonymous) - provider( - AuthProvider.Email( - isDisplayNameRequired = true, - isEmailLinkForceSameDeviceEnabled = true, - isEmailLinkSignInEnabled = false, - emailLinkActionCodeSettings = actionCodeSettings { - url = "https://temp-test-aa342.firebaseapp.com" - handleCodeInApp = true - setAndroidPackageName( - "com.firebase.composeapp", - 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( - applicationId = "792556260059222" - ) + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ChooserScreen( + onHighLevelApiClick = { + startActivity(Intent(this, HighLevelApiDemoActivity::class.java)) + }, + onLowLevelApiClick = { + startActivity(Intent(this, AuthFlowControllerDemoActivity::class.java)) + } ) } - logo = AuthUIAsset.Resource(R.drawable.firebase_auth_120dp) - tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1" - privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" - } - - setContent { - AuthUITheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - FirebaseAuthScreen( - configuration = configuration, - authUI = authUI, - emailLink = emailLink, - onSignInSuccess = { result -> - Log.d("MainActivity", "Authentication success: ${result.user?.uid}") - }, - onSignInFailure = { exception: AuthException -> - Log.e("MainActivity", "Authentication failed", exception) - }, - onSignInCancelled = { - Log.d("MainActivity", "Authentication cancelled") - }, - authenticatedContent = { state, uiContext -> - AppAuthenticatedContent(state, uiContext) - } - ) - } - } } } } +} - @Composable - private fun AppAuthenticatedContent( - state: AuthState, - uiContext: AuthSuccessUiContext +@Composable +fun ChooserScreen( + onHighLevelApiClick: () -> Unit, + onLowLevelApiClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically) ) { - 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 - ) - Text( - "Providers - ${state.user.providerData.map { it.providerId }}", - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - if (state.user.isAnonymous) { - Button( - onClick = { + // Header + Text( + text = "Firebase Auth UI Compose", + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) - } - ) { - Text("Upgrade with Email") - } - } - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = uiContext.onManageMfa) { - Text(stringProvider.manageMfaAction) - } - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = uiContext.onSignOut) { - Text(stringProvider.signOutAction) - } - } - } + Text( + text = "Choose a demo to explore different authentication APIs", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - 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) - } - } + 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 + ) } + } - 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) - } - } + // 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 + ) } + } - else -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - } + 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 + ) } } }