@@ -22,14 +22,29 @@ import com.google.firebase.auth.FirebaseUser
22
22
import com.google.firebase.auth.ktx.auth
23
23
import com.google.firebase.ktx.Firebase
24
24
import android.content.Context
25
+ import androidx.datastore.core.DataStore
26
+ import androidx.datastore.preferences.core.Preferences
27
+ import androidx.datastore.preferences.core.edit
28
+ import androidx.datastore.preferences.core.stringPreferencesKey
29
+ import androidx.datastore.preferences.preferencesDataStore
30
+ import com.firebase.ui.auth.compose.configuration.AuthProvider
31
+ import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
32
+ import com.firebase.ui.auth.util.data.EmailLinkParser
33
+ import com.firebase.ui.auth.util.data.SessionUtils
34
+ import com.google.firebase.auth.ActionCodeSettings
35
+ import com.google.firebase.auth.AuthCredential
36
+ import com.google.firebase.auth.EmailAuthProvider
25
37
import kotlinx.coroutines.CancellationException
26
38
import kotlinx.coroutines.channels.awaitClose
27
39
import kotlinx.coroutines.flow.Flow
28
40
import kotlinx.coroutines.flow.MutableStateFlow
29
41
import kotlinx.coroutines.flow.callbackFlow
42
+ import kotlinx.coroutines.flow.first
30
43
import kotlinx.coroutines.tasks.await
31
44
import java.util.concurrent.ConcurrentHashMap
32
45
46
+ val Context .dataStore: DataStore <Preferences > by preferencesDataStore(name = " com.firebase.ui.auth.util.data.EmailLinkPersistenceManager" )
47
+
33
48
/* *
34
49
* The central class that coordinates all authentication operations for Firebase Auth UI Compose.
35
50
* This class manages UI state and provides methods for signing in, signing up, and managing
@@ -168,7 +183,8 @@ class FirebaseAuthUI private constructor(
168
183
// Check if email verification is required
169
184
if (! currentUser.isEmailVerified &&
170
185
currentUser.email != null &&
171
- currentUser.providerData.any { it.providerId == " password" }) {
186
+ currentUser.providerData.any { it.providerId == " password" }
187
+ ) {
172
188
AuthState .RequiresEmailVerification (
173
189
user = currentUser,
174
190
email = currentUser.email!!
@@ -213,6 +229,249 @@ class FirebaseAuthUI private constructor(
213
229
_authStateFlow .value = state
214
230
}
215
231
232
+ internal suspend fun createOrLinkUserWithEmailAndPassword (
233
+ config : AuthUIConfiguration ,
234
+ provider : AuthProvider .Email ,
235
+ email : String ,
236
+ password : String
237
+ ) {
238
+ try {
239
+ updateAuthState(AuthState .Loading (" Creating user..." ))
240
+ if (provider.canUpgradeAnonymous(config, auth)) {
241
+ val credential = EmailAuthProvider .getCredential(email, password)
242
+ auth.currentUser?.linkWithCredential(credential)?.await()
243
+ } else {
244
+ auth.createUserWithEmailAndPassword(email, password).await()
245
+ }
246
+ updateAuthState(AuthState .Idle )
247
+ } catch (e: CancellationException ) {
248
+ val cancelledException = AuthException .AuthCancelledException (
249
+ message = " Create or link user with email and password was cancelled" ,
250
+ cause = e
251
+ )
252
+ updateAuthState(AuthState .Error (cancelledException))
253
+ throw cancelledException
254
+ } catch (e: AuthException ) {
255
+ updateAuthState(AuthState .Error (e))
256
+ throw e
257
+ } catch (e: Exception ) {
258
+ val authException = AuthException .from(e)
259
+ updateAuthState(AuthState .Error (authException))
260
+ throw authException
261
+ }
262
+ }
263
+
264
+ internal suspend fun signInAndLinkWithCredential (
265
+ config : AuthUIConfiguration ,
266
+ provider : AuthProvider .Email ,
267
+ credential : AuthCredential
268
+ ) {
269
+ try {
270
+ updateAuthState(AuthState .Loading (" Signing in user..." ))
271
+ if (provider.canUpgradeAnonymous(config, auth)) {
272
+ auth.currentUser?.linkWithCredential(credential)?.await()
273
+ } else {
274
+ auth.signInWithCredential(credential).await()
275
+ }
276
+ updateAuthState(AuthState .Idle )
277
+ } catch (e: CancellationException ) {
278
+ val cancelledException = AuthException .AuthCancelledException (
279
+ message = " Sign in and link with credential was cancelled" ,
280
+ cause = e
281
+ )
282
+ updateAuthState(AuthState .Error (cancelledException))
283
+ throw cancelledException
284
+ } catch (e: AuthException ) {
285
+ updateAuthState(AuthState .Error (e))
286
+ throw e
287
+ } catch (e: Exception ) {
288
+ val authException = AuthException .from(e)
289
+ updateAuthState(AuthState .Error (authException))
290
+ throw authException
291
+ }
292
+ }
293
+
294
+ internal suspend fun sendSignInLinkToEmail (
295
+ context : Context ,
296
+ config : AuthUIConfiguration ,
297
+ provider : AuthProvider .Email ,
298
+ email : String ,
299
+ ) {
300
+ try {
301
+ updateAuthState(AuthState .Loading (" Sending sign in email link..." ))
302
+
303
+ // Get anonymousUserId if can upgrade anonymously else default to empty string.
304
+ // NOTE: check for empty string instead of null to validate anonymous user ID matches
305
+ // when sign in from email link
306
+ val anonymousUserId =
307
+ if (provider.canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid
308
+ ? : " " ) else " "
309
+
310
+ // Generate sessionId
311
+ val sessionId =
312
+ SessionUtils .generateRandomAlphaNumericString(AuthProvider .Email .SESSION_ID_LENGTH )
313
+
314
+ // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same
315
+ // device flag
316
+ val updatedActionCodeSettings =
317
+ provider.addSessionInfoToActionCodeSettings(sessionId, anonymousUserId)
318
+
319
+ auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await()
320
+
321
+ // Save Email to dataStore for use in signInWithEmailLink
322
+ context.dataStore.edit { prefs ->
323
+ prefs[AuthProvider .Email .KEY_EMAIL ] = email
324
+ prefs[AuthProvider .Email .KEY_ANONYMOUS_USER_ID ] = anonymousUserId
325
+ prefs[AuthProvider .Email .KEY_SESSION_ID ] = sessionId
326
+ }
327
+ updateAuthState(AuthState .Idle )
328
+ } catch (e: CancellationException ) {
329
+ val cancelledException = AuthException .AuthCancelledException (
330
+ message = " Send sign in link to email was cancelled" ,
331
+ cause = e
332
+ )
333
+ updateAuthState(AuthState .Error (cancelledException))
334
+ throw cancelledException
335
+ } catch (e: AuthException ) {
336
+ updateAuthState(AuthState .Error (e))
337
+ throw e
338
+ } catch (e: Exception ) {
339
+ val authException = AuthException .from(e)
340
+ updateAuthState(AuthState .Error (authException))
341
+ throw authException
342
+ }
343
+ }
344
+
345
+ /* *
346
+ * Signs in a user using an email link (passwordless authentication).
347
+ *
348
+ * This method completes the email link sign-in flow after the user clicks the magic link
349
+ * sent to their email. It validates the link, extracts session information, and either
350
+ * signs in the user normally or upgrades an anonymous account based on configuration.
351
+ *
352
+ * **Flow:**
353
+ * 1. User receives email with magic link
354
+ * 2. User clicks link, app opens via deep link
355
+ * 3. Activity extracts emailLink from Intent.data
356
+ * 4. This method validates and completes sign-in
357
+ *
358
+ * @param config The [AuthUIConfiguration] containing authentication settings
359
+ * @param provider The [AuthProvider.Email] configuration with email-link settings
360
+ * @param email The email address of the user (retrieved from DataStore or user input)
361
+ * @param emailLink The complete deep link URL received from the Intent.
362
+ *
363
+ * This URL contains:
364
+ * - Firebase action code (oobCode) for authentication
365
+ * - Session ID (ui_sid) for same-device validation
366
+ * - Anonymous user ID (ui_auid) if upgrading anonymous account
367
+ * - Force same-device flag (ui_sd) for security enforcement
368
+ *
369
+ * Example:
370
+ * `https://yourapp.page.link/emailSignIn?oobCode=ABC123&continueUrl=...`
371
+ *
372
+ * @throws AuthException.InvalidCredentialsException if the email link is invalid or expired
373
+ * @throws AuthException.AuthCancelledException if the operation is cancelled
374
+ * @throws AuthException.NetworkException if a network error occurs
375
+ * @throws AuthException.UnknownException for other errors
376
+ *
377
+ * @see sendSignInLinkToEmail for sending the initial email link
378
+ */
379
+ internal suspend fun signInWithEmailLink (
380
+ context : Context ,
381
+ config : AuthUIConfiguration ,
382
+ provider : AuthProvider .Email ,
383
+ email : String ,
384
+ emailLink : String ,
385
+ ) {
386
+ try {
387
+ updateAuthState(AuthState .Loading (" Signing in with email link..." ))
388
+
389
+ // Validate link format
390
+ if (! auth.isSignInWithEmailLink(emailLink)) {
391
+ throw AuthException .InvalidCredentialsException (" Invalid email link" )
392
+ }
393
+
394
+ // Parses email link for session data and returns sessionId, anonymousUserId,
395
+ // force same device flag etc.
396
+ val parser = EmailLinkParser (emailLink)
397
+ val sessionIdFromLink = parser.sessionId
398
+ val anonymousUserIdFromLink = parser.anonymousUserId
399
+
400
+ // Retrieve stored session id from DataStore
401
+ val storedSessionId = context.dataStore.data.first()[AuthProvider .Email .KEY_SESSION_ID ]
402
+
403
+ // Validate same-device
404
+ if (provider.isDifferentDevice(
405
+ sessionIdFromLocal = storedSessionId,
406
+ sessionIdFromLink = sessionIdFromLink
407
+ )
408
+ ) {
409
+ if (provider.isEmailLinkForceSameDeviceEnabled
410
+ || ! anonymousUserIdFromLink.isNullOrEmpty()
411
+ ) {
412
+ throw AuthException .InvalidCredentialsException (
413
+ " Email link must be" +
414
+ " opened on the same device"
415
+ )
416
+ }
417
+
418
+ // TODO(demolaf): handle different device flow -
419
+ // would need to prompt user for email and start flow on new device
420
+ // Different device flow - prompt for email
421
+ // This is a FUTURE ticket - not part of P2 core implementation
422
+ // The UI layer needs to handle this by:
423
+ // 1. Detecting that email is null/missing from DataStore
424
+ // 2. Showing an EmailPromptScreen composable
425
+ // 3. User enters email
426
+ // 4. Retrying signInWithEmailLink() with user-provided email
427
+
428
+ // For now, throw an exception since we don't have the UI
429
+ throw AuthException .InvalidCredentialsException (
430
+ " Email not found. Please enter your email to complete sign-in."
431
+ )
432
+ }
433
+
434
+ // Validate anonymous user ID matches
435
+ if (! anonymousUserIdFromLink.isNullOrEmpty()) {
436
+ val currentUser = auth.currentUser
437
+ if (currentUser == null
438
+ || ! currentUser.isAnonymous
439
+ || currentUser.uid != anonymousUserIdFromLink
440
+ ) {
441
+ throw AuthException .InvalidCredentialsException (
442
+ " Anonymous " +
443
+ " user mismatch"
444
+ )
445
+ }
446
+ }
447
+
448
+ // Create credential and sign in
449
+ val emailLinkCredential = EmailAuthProvider .getCredentialWithLink(email, emailLink)
450
+ signInAndLinkWithCredential(config, provider, emailLinkCredential)
451
+
452
+ // Clear DataStore after success
453
+ context.dataStore.edit { prefs ->
454
+ prefs.remove(AuthProvider .Email .KEY_SESSION_ID )
455
+ prefs.remove(AuthProvider .Email .KEY_EMAIL )
456
+ prefs.remove(AuthProvider .Email .KEY_ANONYMOUS_USER_ID )
457
+ }
458
+ } catch (e: CancellationException ) {
459
+ val cancelledException = AuthException .AuthCancelledException (
460
+ message = " Sign in with email link was cancelled" ,
461
+ cause = e
462
+ )
463
+ updateAuthState(AuthState .Error (cancelledException))
464
+ throw cancelledException
465
+ } catch (e: AuthException ) {
466
+ updateAuthState(AuthState .Error (e))
467
+ throw e
468
+ } catch (e: Exception ) {
469
+ val authException = AuthException .from(e)
470
+ updateAuthState(AuthState .Error (authException))
471
+ throw authException
472
+ }
473
+ }
474
+
216
475
/* *
217
476
* Signs out the current user and clears authentication state.
218
477
*
@@ -374,7 +633,7 @@ class FirebaseAuthUI private constructor(
374
633
} catch (e: IllegalStateException ) {
375
634
throw IllegalStateException (
376
635
" Default FirebaseApp is not initialized. " +
377
- " Make sure to call FirebaseApp.initializeApp(Context) first." ,
636
+ " Make sure to call FirebaseApp.initializeApp(Context) first." ,
378
637
e
379
638
)
380
639
}
0 commit comments