Skip to content

Commit 7aed4b8

Browse files
authored
feat: Core: Sign-out & Delete (#2226)
1 parent 29a443f commit 7aed4b8

File tree

2 files changed

+332
-0
lines changed

2 files changed

+332
-0
lines changed

auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener
2121
import com.google.firebase.auth.FirebaseUser
2222
import com.google.firebase.auth.ktx.auth
2323
import com.google.firebase.ktx.Firebase
24+
import android.content.Context
25+
import kotlinx.coroutines.CancellationException
2426
import kotlinx.coroutines.channels.awaitClose
2527
import kotlinx.coroutines.flow.Flow
2628
import kotlinx.coroutines.flow.MutableStateFlow
2729
import kotlinx.coroutines.flow.callbackFlow
30+
import kotlinx.coroutines.tasks.await
2831
import java.util.concurrent.ConcurrentHashMap
2932

3033
/**
@@ -210,6 +213,136 @@ class FirebaseAuthUI private constructor(
210213
_authStateFlow.value = state
211214
}
212215

216+
/**
217+
* Signs out the current user and clears authentication state.
218+
*
219+
* This method signs out the user from Firebase Auth and updates the auth state flow
220+
* to reflect the change. The operation is performed asynchronously and will emit
221+
* appropriate states during the process.
222+
*
223+
* **Example:**
224+
* ```kotlin
225+
* val authUI = FirebaseAuthUI.getInstance()
226+
*
227+
* try {
228+
* authUI.signOut(context)
229+
* // User is now signed out
230+
* } catch (e: AuthException) {
231+
* // Handle sign-out error
232+
* when (e) {
233+
* is AuthException.AuthCancelledException -> {
234+
* // User cancelled sign-out
235+
* }
236+
* else -> {
237+
* // Other error occurred
238+
* }
239+
* }
240+
* }
241+
* ```
242+
*
243+
* @param context The Android [Context] for any required UI operations
244+
* @throws AuthException.AuthCancelledException if the operation is cancelled
245+
* @throws AuthException.NetworkException if a network error occurs
246+
* @throws AuthException.UnknownException for other errors
247+
* @since 10.0.0
248+
*/
249+
suspend fun signOut(context: Context) {
250+
try {
251+
// Update state to loading
252+
updateAuthState(AuthState.Loading("Signing out..."))
253+
254+
// Sign out from Firebase Auth
255+
auth.signOut()
256+
257+
// Update state to idle (user signed out)
258+
updateAuthState(AuthState.Idle)
259+
260+
} catch (e: CancellationException) {
261+
// Handle coroutine cancellation
262+
val cancelledException = AuthException.AuthCancelledException(
263+
message = "Sign-out was cancelled",
264+
cause = e
265+
)
266+
updateAuthState(AuthState.Error(cancelledException))
267+
throw cancelledException
268+
} catch (e: AuthException) {
269+
// Already mapped AuthException, just update state and re-throw
270+
updateAuthState(AuthState.Error(e))
271+
throw e
272+
} catch (e: Exception) {
273+
// Map to appropriate AuthException
274+
val authException = AuthException.from(e)
275+
updateAuthState(AuthState.Error(authException))
276+
throw authException
277+
}
278+
}
279+
280+
/**
281+
* Deletes the current user account and clears authentication state.
282+
*
283+
* This method deletes the current user's account from Firebase Auth. If the user
284+
* hasn't signed in recently, it will throw an exception requiring reauthentication.
285+
* The operation is performed asynchronously and will emit appropriate states during
286+
* the process.
287+
*
288+
* **Example:**
289+
* ```kotlin
290+
* val authUI = FirebaseAuthUI.getInstance()
291+
*
292+
* try {
293+
* authUI.delete(context)
294+
* // User account is now deleted
295+
* } catch (e: AuthException.InvalidCredentialsException) {
296+
* // Recent login required - show reauthentication UI
297+
* handleReauthentication()
298+
* } catch (e: AuthException) {
299+
* // Handle other errors
300+
* }
301+
* ```
302+
*
303+
* @param context The Android [Context] for any required UI operations
304+
* @throws AuthException.InvalidCredentialsException if reauthentication is required
305+
* @throws AuthException.AuthCancelledException if the operation is cancelled
306+
* @throws AuthException.NetworkException if a network error occurs
307+
* @throws AuthException.UnknownException for other errors
308+
* @since 10.0.0
309+
*/
310+
suspend fun delete(context: Context) {
311+
try {
312+
val currentUser = auth.currentUser
313+
?: throw AuthException.UserNotFoundException(
314+
message = "No user is currently signed in"
315+
)
316+
317+
// Update state to loading
318+
updateAuthState(AuthState.Loading("Deleting account..."))
319+
320+
// Delete the user account
321+
currentUser.delete().await()
322+
323+
// Update state to idle (user deleted and signed out)
324+
updateAuthState(AuthState.Idle)
325+
326+
} catch (e: CancellationException) {
327+
// Handle coroutine cancellation
328+
val cancelledException = AuthException.AuthCancelledException(
329+
message = "Account deletion was cancelled",
330+
cause = e
331+
)
332+
updateAuthState(AuthState.Error(cancelledException))
333+
throw cancelledException
334+
} catch (e: AuthException) {
335+
// Already mapped AuthException, just update state and re-throw
336+
updateAuthState(AuthState.Error(e))
337+
throw e
338+
} catch (e: Exception) {
339+
// Map to appropriate AuthException
340+
val authException = AuthException.from(e)
341+
updateAuthState(AuthState.Error(authException))
342+
throw authException
343+
}
344+
}
345+
213346
companion object {
214347
/** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */
215348
private val instanceCache = ConcurrentHashMap<String, FirebaseAuthUI>()

auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,25 @@ package com.firebase.ui.auth.compose
1717
import androidx.test.core.app.ApplicationProvider
1818
import com.google.common.truth.Truth.assertThat
1919
import com.google.firebase.FirebaseApp
20+
import com.google.firebase.FirebaseException
2021
import com.google.firebase.FirebaseOptions
2122
import com.google.firebase.auth.FirebaseAuth
23+
import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException
24+
import com.google.firebase.auth.FirebaseUser
25+
import com.google.android.gms.tasks.Task
26+
import com.google.android.gms.tasks.TaskCompletionSource
27+
import kotlinx.coroutines.CancellationException
28+
import kotlinx.coroutines.test.runTest
2229
import org.junit.After
2330
import org.junit.Before
2431
import org.junit.Test
2532
import org.junit.runner.RunWith
2633
import org.mockito.Mock
2734
import org.mockito.Mockito.`when`
2835
import org.mockito.Mockito.mock
36+
import org.mockito.Mockito.verify
37+
import org.mockito.Mockito.doNothing
38+
import org.mockito.Mockito.doThrow
2939
import org.mockito.MockitoAnnotations
3040
import org.robolectric.RobolectricTestRunner
3141
import org.robolectric.annotation.Config
@@ -323,4 +333,193 @@ class FirebaseAuthUITest {
323333
// Only one instance should be cached
324334
assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1)
325335
}
336+
337+
// =============================================================================================
338+
// Sign Out Tests
339+
// =============================================================================================
340+
341+
@Test
342+
fun `signOut() successfully signs out user and updates state`() = runTest {
343+
// Setup mock auth
344+
val mockAuth = mock(FirebaseAuth::class.java)
345+
doNothing().`when`(mockAuth).signOut()
346+
347+
// Create instance with mock auth
348+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
349+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
350+
351+
// Perform sign out
352+
instance.signOut(context)
353+
354+
// Verify signOut was called on Firebase Auth
355+
verify(mockAuth).signOut()
356+
}
357+
358+
@Test
359+
fun `signOut() handles Firebase exception and maps to AuthException`() = runTest {
360+
// Setup mock auth that throws exception
361+
val mockAuth = mock(FirebaseAuth::class.java)
362+
val runtimeException = RuntimeException("Network error")
363+
doThrow(runtimeException).`when`(mockAuth).signOut()
364+
365+
// Create instance with mock auth
366+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
367+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
368+
369+
// Perform sign out and expect exception
370+
try {
371+
instance.signOut(context)
372+
assertThat(false).isTrue() // Should not reach here
373+
} catch (e: AuthException) {
374+
assertThat(e).isInstanceOf(AuthException.UnknownException::class.java)
375+
assertThat(e.cause).isEqualTo(runtimeException)
376+
}
377+
}
378+
379+
@Test
380+
fun `signOut() handles cancellation and maps to AuthCancelledException`() = runTest {
381+
// Setup mock auth
382+
val mockAuth = mock(FirebaseAuth::class.java)
383+
val cancellationException = CancellationException("Operation cancelled")
384+
doThrow(cancellationException).`when`(mockAuth).signOut()
385+
386+
// Create instance with mock auth
387+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
388+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
389+
390+
// Perform sign out and expect cancellation exception
391+
try {
392+
instance.signOut(context)
393+
assertThat(false).isTrue() // Should not reach here
394+
} catch (e: AuthException.AuthCancelledException) {
395+
assertThat(e.message).contains("cancelled")
396+
assertThat(e.cause).isInstanceOf(CancellationException::class.java)
397+
}
398+
}
399+
400+
// =============================================================================================
401+
// Delete Account Tests
402+
// =============================================================================================
403+
404+
@Test
405+
fun `delete() successfully deletes user account and updates state`() = runTest {
406+
// Setup mock user and auth
407+
val mockUser = mock(FirebaseUser::class.java)
408+
val mockAuth = mock(FirebaseAuth::class.java)
409+
val taskCompletionSource = TaskCompletionSource<Void>()
410+
taskCompletionSource.setResult(null) // Simulate successful deletion
411+
412+
`when`(mockAuth.currentUser).thenReturn(mockUser)
413+
`when`(mockUser.delete()).thenReturn(taskCompletionSource.task)
414+
415+
// Create instance with mock auth
416+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
417+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
418+
419+
// Perform delete
420+
instance.delete(context)
421+
422+
// Verify delete was called on user
423+
verify(mockUser).delete()
424+
}
425+
426+
@Test
427+
fun `delete() throws UserNotFoundException when no user is signed in`() = runTest {
428+
// Setup mock auth with no current user
429+
val mockAuth = mock(FirebaseAuth::class.java)
430+
`when`(mockAuth.currentUser).thenReturn(null)
431+
432+
// Create instance with mock auth
433+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
434+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
435+
436+
// Perform delete and expect exception
437+
try {
438+
instance.delete(context)
439+
assertThat(false).isTrue() // Should not reach here
440+
} catch (e: AuthException.UserNotFoundException) {
441+
assertThat(e.message).contains("No user is currently signed in")
442+
}
443+
}
444+
445+
@Test
446+
fun `delete() handles recent login required exception`() = runTest {
447+
// Setup mock user and auth
448+
val mockUser = mock(FirebaseUser::class.java)
449+
val mockAuth = mock(FirebaseAuth::class.java)
450+
val taskCompletionSource = TaskCompletionSource<Void>()
451+
val recentLoginException = FirebaseAuthRecentLoginRequiredException(
452+
"ERROR_REQUIRES_RECENT_LOGIN",
453+
"Recent login required"
454+
)
455+
taskCompletionSource.setException(recentLoginException)
456+
457+
`when`(mockAuth.currentUser).thenReturn(mockUser)
458+
`when`(mockUser.delete()).thenReturn(taskCompletionSource.task)
459+
460+
// Create instance with mock auth
461+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
462+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
463+
464+
// Perform delete and expect mapped exception
465+
try {
466+
instance.delete(context)
467+
assertThat(false).isTrue() // Should not reach here
468+
} catch (e: AuthException.InvalidCredentialsException) {
469+
assertThat(e.message).contains("Recent login required")
470+
assertThat(e.cause).isEqualTo(recentLoginException)
471+
}
472+
}
473+
474+
@Test
475+
fun `delete() handles cancellation and maps to AuthCancelledException`() = runTest {
476+
// Setup mock user and auth
477+
val mockUser = mock(FirebaseUser::class.java)
478+
val mockAuth = mock(FirebaseAuth::class.java)
479+
val taskCompletionSource = TaskCompletionSource<Void>()
480+
val cancellationException = CancellationException("Operation cancelled")
481+
taskCompletionSource.setException(cancellationException)
482+
483+
`when`(mockAuth.currentUser).thenReturn(mockUser)
484+
`when`(mockUser.delete()).thenReturn(taskCompletionSource.task)
485+
486+
// Create instance with mock auth
487+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
488+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
489+
490+
// Perform delete and expect cancellation exception
491+
try {
492+
instance.delete(context)
493+
assertThat(false).isTrue() // Should not reach here
494+
} catch (e: AuthException.AuthCancelledException) {
495+
assertThat(e.message).contains("cancelled")
496+
assertThat(e.cause).isInstanceOf(CancellationException::class.java)
497+
}
498+
}
499+
500+
@Test
501+
fun `delete() handles Firebase network exception`() = runTest {
502+
// Setup mock user and auth
503+
val mockUser = mock(FirebaseUser::class.java)
504+
val mockAuth = mock(FirebaseAuth::class.java)
505+
val taskCompletionSource = TaskCompletionSource<Void>()
506+
val networkException = FirebaseException("Network error")
507+
taskCompletionSource.setException(networkException)
508+
509+
`when`(mockAuth.currentUser).thenReturn(mockUser)
510+
`when`(mockUser.delete()).thenReturn(taskCompletionSource.task)
511+
512+
// Create instance with mock auth
513+
val instance = FirebaseAuthUI.create(defaultApp, mockAuth)
514+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
515+
516+
// Perform delete and expect mapped exception
517+
try {
518+
instance.delete(context)
519+
assertThat(false).isTrue() // Should not reach here
520+
} catch (e: AuthException.NetworkException) {
521+
assertThat(e.message).contains("Network error")
522+
assertThat(e.cause).isEqualTo(networkException)
523+
}
524+
}
326525
}

0 commit comments

Comments
 (0)