Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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<String, FirebaseAuthUI>()
Expand Down
199 changes: 199 additions & 0 deletions auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@ 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
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
Expand Down Expand Up @@ -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<android.content.Context>()

// 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<android.content.Context>()

// 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<android.content.Context>()

// 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<Void>()
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<android.content.Context>()

// 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<android.content.Context>()

// 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<Void>()
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<android.content.Context>()

// 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<Void>()
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<android.content.Context>()

// 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<Void>()
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<android.content.Context>()

// 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)
}
}
}