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 58bfab344..fe1f6cf80 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 @@ -21,10 +21,13 @@ 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 android.content.Context +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.tasks.await import java.util.concurrent.ConcurrentHashMap /** @@ -210,6 +213,136 @@ class FirebaseAuthUI private constructor( _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() + + // 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() diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt index 277d10a95..5fd0d201c 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -17,8 +17,15 @@ package com.firebase.ui.auth.compose import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseException import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseUser +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -26,6 +33,9 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.`when` import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doThrow import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -323,4 +333,193 @@ class FirebaseAuthUITest { // Only one instance should be cached assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) } + + // ============================================================================================= + // Sign Out Tests + // ============================================================================================= + + @Test + fun `signOut() successfully signs out user and updates state`() = runTest { + // Setup mock auth + val mockAuth = mock(FirebaseAuth::class.java) + doNothing().`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out + instance.signOut(context) + + // Verify signOut was called on Firebase Auth + verify(mockAuth).signOut() + } + + @Test + fun `signOut() handles Firebase exception and maps to AuthException`() = runTest { + // Setup mock auth that throws exception + val mockAuth = mock(FirebaseAuth::class.java) + val runtimeException = RuntimeException("Network error") + doThrow(runtimeException).`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out and expect exception + try { + instance.signOut(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException) { + assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(e.cause).isEqualTo(runtimeException) + } + } + + @Test + fun `signOut() handles cancellation and maps to AuthCancelledException`() = runTest { + // Setup mock auth + val mockAuth = mock(FirebaseAuth::class.java) + val cancellationException = CancellationException("Operation cancelled") + doThrow(cancellationException).`when`(mockAuth).signOut() + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform sign out and expect cancellation exception + try { + instance.signOut(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } + + // ============================================================================================= + // Delete Account Tests + // ============================================================================================= + + @Test + fun `delete() successfully deletes user account and updates state`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setResult(null) // Simulate successful deletion + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete + instance.delete(context) + + // Verify delete was called on user + verify(mockUser).delete() + } + + @Test + fun `delete() throws UserNotFoundException when no user is signed in`() = runTest { + // Setup mock auth with no current user + val mockAuth = mock(FirebaseAuth::class.java) + `when`(mockAuth.currentUser).thenReturn(null) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.UserNotFoundException) { + assertThat(e.message).contains("No user is currently signed in") + } + } + + @Test + fun `delete() handles recent login required exception`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val recentLoginException = FirebaseAuthRecentLoginRequiredException( + "ERROR_REQUIRES_RECENT_LOGIN", + "Recent login required" + ) + taskCompletionSource.setException(recentLoginException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect mapped exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.InvalidCredentialsException) { + assertThat(e.message).contains("Recent login required") + assertThat(e.cause).isEqualTo(recentLoginException) + } + } + + @Test + fun `delete() handles cancellation and maps to AuthCancelledException`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val cancellationException = CancellationException("Operation cancelled") + taskCompletionSource.setException(cancellationException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect cancellation exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.AuthCancelledException) { + assertThat(e.message).contains("cancelled") + assertThat(e.cause).isInstanceOf(CancellationException::class.java) + } + } + + @Test + fun `delete() handles Firebase network exception`() = runTest { + // Setup mock user and auth + val mockUser = mock(FirebaseUser::class.java) + val mockAuth = mock(FirebaseAuth::class.java) + val taskCompletionSource = TaskCompletionSource() + val networkException = FirebaseException("Network error") + taskCompletionSource.setException(networkException) + + `when`(mockAuth.currentUser).thenReturn(mockUser) + `when`(mockUser.delete()).thenReturn(taskCompletionSource.task) + + // Create instance with mock auth + val instance = FirebaseAuthUI.create(defaultApp, mockAuth) + val context = ApplicationProvider.getApplicationContext() + + // Perform delete and expect mapped exception + try { + instance.delete(context) + assertThat(false).isTrue() // Should not reach here + } catch (e: AuthException.NetworkException) { + assertThat(e.message).contains("Network error") + assertThat(e.cause).isEqualTo(networkException) + } + } } \ No newline at end of file