Skip to content

Commit 3b776cc

Browse files
demolafLyokone
andauthored
feat: Anonymous Auth & Upgrade (#2247)
* feat: extract routes from example app to main library * add emulator flag * fixing tests * fix tests * feat: Anonymous sign in and auto upgrade * e2e tests anonymous sign in and auto upgrade when enabled * unit tests for anonymous sign in * fix feedback --------- Co-authored-by: Guillaume Bernos <[email protected]>
1 parent 6e3f755 commit 3b776cc

File tree

15 files changed

+1007
-188
lines changed

15 files changed

+1007
-188
lines changed

auth/src/main/AndroidManifest.xml

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -122,27 +122,6 @@
122122
<data android:scheme="@string/facebook_login_protocol_scheme" />
123123
</intent-filter>
124124
</activity>
125-
126-
<!-- Email Link Sign-In Handler Activity for Compose -->
127-
<!-- This activity handles deep links for passwordless email authentication -->
128-
<!-- The host is automatically read from firebase_web_host in config.xml -->
129-
<!-- NOTE: firebase_web_host must be lowercase (e.g., project-id.firebaseapp.com) -->
130-
<activity
131-
android:name=".compose.ui.screens.EmailSignInLinkHandlerActivity"
132-
android:label=""
133-
android:exported="true"
134-
android:theme="@style/FirebaseUI.Transparent">
135-
<intent-filter android:autoVerify="true">
136-
<action android:name="android.intent.action.VIEW" />
137-
<category android:name="android.intent.category.DEFAULT" />
138-
<category android:name="android.intent.category.BROWSABLE" />
139-
<data
140-
android:scheme="https"
141-
android:host="@string/firebase_web_host"
142-
tools:ignore="AppLinksAutoVerify,AppLinkUrlError" />
143-
</intent-filter>
144-
</activity>
145-
146125
<provider
147126
android:name=".data.client.AuthUiInitProvider"
148127
android:authorities="${applicationId}.authuiinitprovider"

auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvidersBui
2121
import com.firebase.ui.auth.compose.configuration.auth_provider.Provider
2222
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
2323
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
24+
import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset
2425
import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
2526
import com.google.firebase.auth.ActionCodeSettings
2627
import java.util.Locale
@@ -43,7 +44,7 @@ class AuthUIConfigurationBuilder {
4344
var isAnonymousUpgradeEnabled: Boolean = false
4445
var tosUrl: String? = null
4546
var privacyPolicyUrl: String? = null
46-
var logo: ImageVector? = null
47+
var logo: AuthUIAsset? = null
4748
var passwordResetActionCodeSettings: ActionCodeSettings? = null
4849
var isNewEmailAccountsAllowed: Boolean = true
4950
var isDisplayNameRequired: Boolean = true
@@ -171,7 +172,7 @@ class AuthUIConfiguration(
171172
/**
172173
* The logo to display on the authentication screens.
173174
*/
174-
val logo: ImageVector? = null,
175+
val logo: AuthUIAsset? = null,
175176

176177
/**
177178
* Configuration for sending email reset link.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.firebase.ui.auth.compose.configuration.auth_provider
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.remember
5+
import androidx.compose.runtime.rememberCoroutineScope
6+
import com.firebase.ui.auth.compose.AuthException
7+
import com.firebase.ui.auth.compose.AuthState
8+
import com.firebase.ui.auth.compose.FirebaseAuthUI
9+
import kotlinx.coroutines.CancellationException
10+
import kotlinx.coroutines.launch
11+
import kotlinx.coroutines.tasks.await
12+
13+
/**
14+
* Creates a remembered launcher function for anonymous sign-in.
15+
*
16+
* @return A launcher function that starts the anonymous sign-in flow when invoked
17+
*
18+
* @see signInAnonymously
19+
* @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts
20+
*/
21+
@Composable
22+
internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit {
23+
val coroutineScope = rememberCoroutineScope()
24+
return remember(this) {
25+
{
26+
coroutineScope.launch {
27+
try {
28+
signInAnonymously()
29+
} catch (e: AuthException) {
30+
// Already an AuthException, don't re-wrap it
31+
updateAuthState(AuthState.Error(e))
32+
} catch (e: Exception) {
33+
val authException = AuthException.from(e)
34+
updateAuthState(AuthState.Error(authException))
35+
}
36+
}
37+
}
38+
}
39+
}
40+
41+
/**
42+
* Signs in a user anonymously with Firebase Authentication.
43+
*
44+
* This method creates a temporary anonymous user account that can be used for testing
45+
* or as a starting point for users who want to try the app before creating a permanent
46+
* account. Anonymous users can later be upgraded to permanent accounts by linking
47+
* credentials (email/password, social providers, phone, etc.).
48+
*
49+
* **Flow:**
50+
* 1. Updates auth state to loading with "Signing in anonymously..." message
51+
* 2. Calls Firebase Auth's `signInAnonymously()` method
52+
* 3. Updates auth state to idle on success
53+
* 4. Handles cancellation and converts exceptions to [AuthException] types
54+
*
55+
* **Anonymous Account Benefits:**
56+
* - No user data collection required
57+
* - Immediate access to app features
58+
* - Can be upgraded to permanent account later
59+
* - Useful for guest users and app trials
60+
*
61+
* **Account Upgrade:**
62+
* Anonymous accounts can be upgraded to permanent accounts by calling methods like:
63+
* - [signInAndLinkWithCredential] with email/password or social credentials
64+
* - [createOrLinkUserWithEmailAndPassword] for email/password accounts
65+
* - [signInWithPhoneAuthCredential] for phone authentication
66+
*
67+
* **Example: Basic anonymous sign-in**
68+
* ```kotlin
69+
* try {
70+
* firebaseAuthUI.signInAnonymously()
71+
* // User is now signed in anonymously
72+
* // Show app content or prompt for account creation
73+
* } catch (e: AuthException.AuthCancelledException) {
74+
* // User cancelled the sign-in process
75+
* } catch (e: AuthException.NetworkException) {
76+
* // Network error occurred
77+
* }
78+
* ```
79+
*
80+
* **Example: Anonymous sign-in with upgrade flow**
81+
* ```kotlin
82+
* // Step 1: Sign in anonymously
83+
* firebaseAuthUI.signInAnonymously()
84+
*
85+
* // Step 2: Later, upgrade to permanent account
86+
* try {
87+
* firebaseAuthUI.createOrLinkUserWithEmailAndPassword(
88+
* context = context,
89+
* config = authUIConfig,
90+
* provider = emailProvider,
91+
* name = "John Doe",
92+
* email = "[email protected]",
93+
* password = "SecurePass123!"
94+
* )
95+
* // Anonymous account upgraded to permanent email/password account
96+
* } catch (e: AuthException.AccountLinkingRequiredException) {
97+
* // Email already exists - show account linking UI
98+
* }
99+
* ```
100+
*
101+
* @throws AuthException.AuthCancelledException if the coroutine is cancelled
102+
* @throws AuthException.NetworkException if a network error occurs
103+
* @throws AuthException.UnknownException for other authentication errors
104+
*
105+
* @see signInAndLinkWithCredential for upgrading anonymous accounts
106+
* @see createOrLinkUserWithEmailAndPassword for email/password upgrade
107+
* @see signInWithPhoneAuthCredential for phone authentication upgrade
108+
*/
109+
internal suspend fun FirebaseAuthUI.signInAnonymously() {
110+
try {
111+
updateAuthState(AuthState.Loading("Signing in anonymously..."))
112+
auth.signInAnonymously().await()
113+
updateAuthState(AuthState.Idle)
114+
} catch (e: CancellationException) {
115+
val cancelledException = AuthException.AuthCancelledException(
116+
message = "Sign in anonymously was cancelled",
117+
cause = e
118+
)
119+
updateAuthState(AuthState.Error(cancelledException))
120+
throw cancelledException
121+
} catch (e: AuthException) {
122+
updateAuthState(AuthState.Error(e))
123+
throw e
124+
} catch (e: Exception) {
125+
val authException = AuthException.from(e)
126+
updateAuthState(AuthState.Error(authException))
127+
throw authException
128+
}
129+
}

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -635,8 +635,7 @@ abstract class AuthProvider(open val providerId: String, open val name: String)
635635
* An interface to wrap the static `FacebookAuthProvider.getCredential` method to make it testable.
636636
* @suppress
637637
*/
638-
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
639-
interface CredentialProvider {
638+
internal interface CredentialProvider {
640639
fun getCredential(token: String): AuthCredential
641640
}
642641

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,8 +697,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
697697
*
698698
* @see sendSignInLinkToEmail for sending the initial email link
699699
*/
700-
// TODO(demolaf: make this internal when done testing email link sign in with composeapp
701-
suspend fun FirebaseAuthUI.signInWithEmailLink(
700+
internal suspend fun FirebaseAuthUI.signInWithEmailLink(
702701
context: Context,
703702
config: AuthUIConfiguration,
704703
provider: AuthProvider.Email,

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import kotlinx.coroutines.launch
5151
* @see signInWithFacebook
5252
*/
5353
@Composable
54-
fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
54+
internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
5555
context: Context,
5656
config: AuthUIConfiguration,
5757
provider: AuthProvider.Facebook,

auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,10 @@ fun AuthMethodPicker(
122122
}
123123
}
124124
AnnotatedStringResource(
125+
modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp),
125126
context = context,
126127
inPreview = inPreview,
127128
previewText = "By continuing, you accept our Terms of Service and Privacy Policy.",
128-
modifier = Modifier.padding(vertical = 16.dp),
129129
id = R.string.fui_tos_and_pp,
130130
links = arrayOf(
131131
"Terms of Service" to (termsOfServiceUrl ?: ""),

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
2828
import androidx.compose.material3.Scaffold
2929
import androidx.compose.material3.Surface
3030
import androidx.compose.material3.Text
31-
import androidx.compose.material3.TextButton
3231
import androidx.compose.runtime.Composable
3332
import androidx.compose.runtime.LaunchedEffect
3433
import androidx.compose.runtime.collectAsState
@@ -50,11 +49,11 @@ import com.firebase.ui.auth.compose.FirebaseAuthUI
5049
import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
5150
import com.firebase.ui.auth.compose.configuration.MfaConfiguration
5251
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
52+
import com.firebase.ui.auth.compose.configuration.auth_provider.rememberAnonymousSignInHandler
5353
import com.firebase.ui.auth.compose.configuration.auth_provider.rememberSignInWithFacebookLauncher
5454
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink
5555
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
5656
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
57-
import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset
5857
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
5958
import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker
6059
import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen
@@ -98,9 +97,14 @@ fun FirebaseAuthScreen(
9897
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
9998
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
10099

100+
val anonymousProvider = configuration.providers.filterIsInstance<AuthProvider.Anonymous>().firstOrNull()
101101
val emailProvider = configuration.providers.filterIsInstance<AuthProvider.Email>().firstOrNull()
102102
val facebookProvider = configuration.providers.filterIsInstance<AuthProvider.Facebook>().firstOrNull()
103-
val logoAsset = configuration.logo?.let { AuthUIAsset.Vector(it) }
103+
val logoAsset = configuration.logo
104+
105+
val onSignInAnonymously = anonymousProvider?.let {
106+
authUI.rememberAnonymousSignInHandler()
107+
}
104108

105109
val onSignInWithFacebook = facebookProvider?.let {
106110
authUI.rememberSignInWithFacebookLauncher(
@@ -128,6 +132,8 @@ fun FirebaseAuthScreen(
128132
privacyPolicyUrl = configuration.privacyPolicyUrl,
129133
onProviderSelected = { provider ->
130134
when (provider) {
135+
is AuthProvider.Anonymous -> onSignInAnonymously?.invoke()
136+
131137
is AuthProvider.Email -> {
132138
navController.navigate(AuthRoute.Email.route)
133139
}
@@ -226,6 +232,9 @@ fun FirebaseAuthScreen(
226232
Log.e("FirebaseAuthScreen", "Failed to refresh user", e)
227233
}
228234
}
235+
},
236+
onNavigate = { route ->
237+
navController.navigate(route.route)
229238
}
230239
)
231240
}
@@ -431,7 +440,7 @@ fun FirebaseAuthScreen(
431440
}
432441
}
433442

434-
private sealed class AuthRoute(val route: String) {
443+
sealed class AuthRoute(val route: String) {
435444
object MethodPicker : AuthRoute("auth_method_picker")
436445
object Email : AuthRoute("auth_email")
437446
object Phone : AuthRoute("auth_phone")
@@ -445,7 +454,8 @@ data class AuthSuccessUiContext(
445454
val stringProvider: AuthUIStringProvider,
446455
val onSignOut: () -> Unit,
447456
val onManageMfa: () -> Unit,
448-
val onReloadUser: () -> Unit
457+
val onReloadUser: () -> Unit,
458+
val onNavigate: (AuthRoute) -> Unit,
449459
)
450460

451461
@Composable

auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.firebase.ui.auth.R
2323
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
2424
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
2525
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
26+
import com.firebase.ui.auth.compose.configuration.theme.AuthUIAsset
2627
import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
2728
import com.google.common.truth.Truth.assertThat
2829
import com.google.firebase.auth.actionCodeSettings
@@ -98,6 +99,7 @@ class AuthUIConfigurationTest {
9899
url = "https://example.com/verify"
99100
handleCodeInApp = true
100101
}
102+
val logoAsset = AuthUIAsset.Vector(Icons.Default.AccountCircle)
101103

102104
val config = authUIConfiguration {
103105
context = applicationContext
@@ -122,7 +124,7 @@ class AuthUIConfigurationTest {
122124
isAnonymousUpgradeEnabled = true
123125
tosUrl = "https://example.com/tos"
124126
privacyPolicyUrl = "https://example.com/privacy"
125-
logo = Icons.Default.AccountCircle
127+
logo = logoAsset
126128
passwordResetActionCodeSettings = customPasswordResetActionCodeSettings
127129
isNewEmailAccountsAllowed = false
128130
isDisplayNameRequired = false
@@ -139,7 +141,7 @@ class AuthUIConfigurationTest {
139141
assertThat(config.isAnonymousUpgradeEnabled).isTrue()
140142
assertThat(config.tosUrl).isEqualTo("https://example.com/tos")
141143
assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy")
142-
assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle)
144+
assertThat(config.logo).isEqualTo(logoAsset)
143145
assertThat(config.passwordResetActionCodeSettings)
144146
.isEqualTo(customPasswordResetActionCodeSettings)
145147
assertThat(config.isNewEmailAccountsAllowed).isFalse()

0 commit comments

Comments
 (0)