Skip to content

Commit ddf6719

Browse files
authored
feat: EmailAuthScreen (Stateful + Slot) (#2236)
* feat: AuthMethodPicker, logo and provider theme style * chore: organize folder structure * feat: TOS and PP footer, ui tests for AuthMethodPicker * chore: tests folder structure * chore: use version catalog for compose deps * feat: AuthTextField with validation * test: AuthTextField and field validations * chore: update doc comments * wip: Email Provider integration * chore: upgrade mockito, fix: spying mocked objects in new library * wip: Email provider integration * wip: Email provider integration * wip: Email provider integration * feat: Email provider integration * wip: SignIn, SignUp, ResetPassword flows * refactor: remove libs.versions.toml catalog file * add sample app compose module * wip: SignInUI and EmailAuthScreen sample * feat: Email provider integration - added: sign in, sign up, reset password, email link and anonymous auto upgrade - upgrade mockito - fixed spying mocked objects in new library test error * wip: SignUp UI * feat: add PasswordResetLinkSent state * fix: use isSecureTextField for password fields * wip: SignUp * fix: passwordResetActionCodeSettings for send password reset link * fix: combine Firebase and internal auth state flows to prioritize non-idle internal updates * wip: SignUp * chore: remove unused methods * chore: remove unused comments and code * chore: remove unused imports, reformat * chore: remove comments * chore: remove comments * handle authState exceptions * fix: mockito 5 upgrade stubbing issues * wip: Email link, deep link * chore: add copyright message * refactor: rename to emailLinkActionCodeSettings in AuthProvider.Email and passwordResetActionCodeSettings in AuthUIConfiguration * feat: add dark theme * feat: Email sign in link * fix: test doesn't capture initial Idle state * fix: CI run issues * fix: CI run issues * fix: opt out of edge to edge in app module * fix: remove opt out of edge to edge in app module
1 parent 10b6dc7 commit ddf6719

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2304
-163
lines changed

auth/src/main/AndroidManifest.xml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@
8383

8484
<activity
8585
android:name=".ui.email.EmailLinkCatcherActivity"
86-
android:exported="false"
8786
android:label=""
87+
android:exported="false"
8888
android:theme="@style/FirebaseUI.Transparent"
8989
android:windowSoftInputMode="adjustResize" />
9090

@@ -119,6 +119,16 @@
119119
</intent-filter>
120120
</activity>
121121

122+
<!-- Email Link Sign-In Handler Activity for Compose -->
123+
<!-- IMPORTANT: This activity is NOT exported by default -->
124+
<!-- Users must declare this activity with an intent filter in their app's AndroidManifest.xml -->
125+
<!-- See documentation for setup instructions -->
126+
<activity
127+
android:name=".compose.ui.screens.EmailSignInLinkHandlerActivity"
128+
android:label=""
129+
android:exported="false"
130+
android:theme="@style/FirebaseUI.Transparent" />
131+
122132
<provider
123133
android:name=".data.client.AuthUiInitProvider"
124134
android:authorities="${applicationId}.authuiinitprovider"

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ abstract class AuthState private constructor() {
243243
override fun toString(): String = "AuthState.PasswordResetLinkSent"
244244
}
245245

246+
/**
247+
* Email sign in link has been sent to the user's email.
248+
*/
249+
class EmailSignInLinkSent : AuthState() {
250+
override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
251+
override fun hashCode(): Int = javaClass.hashCode()
252+
override fun toString(): String = "AuthState.EmailSignInLinkSent"
253+
}
254+
246255
companion object {
247256
/**
248257
* Creates an Idle state instance.

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

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import kotlinx.coroutines.channels.awaitClose
2727
import kotlinx.coroutines.flow.Flow
2828
import kotlinx.coroutines.flow.MutableStateFlow
2929
import kotlinx.coroutines.flow.callbackFlow
30+
import kotlinx.coroutines.flow.combine
31+
import kotlinx.coroutines.flow.distinctUntilChanged
32+
import kotlinx.coroutines.flow.drop
33+
import kotlinx.coroutines.flow.merge
34+
import kotlinx.coroutines.flow.onStart
3035
import kotlinx.coroutines.tasks.await
3136
import java.util.concurrent.ConcurrentHashMap
3237

@@ -153,53 +158,58 @@ class FirebaseAuthUI private constructor(
153158
*
154159
* @return A [Flow] of [AuthState] that emits authentication state changes
155160
*/
156-
fun authStateFlow(): Flow<AuthState> = callbackFlow {
157-
// Set initial state based on current auth state
158-
val initialState = auth.currentUser?.let { user ->
159-
AuthState.Success(result = null, user = user, isNewUser = false)
160-
} ?: AuthState.Idle
161+
fun authStateFlow(): Flow<AuthState> {
162+
// Create a flow from FirebaseAuth state listener
163+
val firebaseAuthFlow = callbackFlow {
164+
// Set initial state based on current auth state
165+
val initialState = auth.currentUser?.let { user ->
166+
AuthState.Success(result = null, user = user, isNewUser = false)
167+
} ?: AuthState.Idle
161168

162-
trySend(initialState)
169+
trySend(initialState)
163170

164-
// Create auth state listener
165-
val authStateListener = AuthStateListener { firebaseAuth ->
166-
val currentUser = firebaseAuth.currentUser
167-
val state = if (currentUser != null) {
168-
// Check if email verification is required
169-
if (!currentUser.isEmailVerified &&
170-
currentUser.email != null &&
171-
currentUser.providerData.any { it.providerId == "password" }
172-
) {
173-
AuthState.RequiresEmailVerification(
174-
user = currentUser,
175-
email = currentUser.email!!
176-
)
171+
// Create auth state listener
172+
val authStateListener = AuthStateListener { firebaseAuth ->
173+
val currentUser = firebaseAuth.currentUser
174+
val state = if (currentUser != null) {
175+
// Check if email verification is required
176+
if (!currentUser.isEmailVerified &&
177+
currentUser.email != null &&
178+
currentUser.providerData.any { it.providerId == "password" }
179+
) {
180+
AuthState.RequiresEmailVerification(
181+
user = currentUser,
182+
email = currentUser.email!!
183+
)
184+
} else {
185+
AuthState.Success(
186+
result = null,
187+
user = currentUser,
188+
isNewUser = false
189+
)
190+
}
177191
} else {
178-
AuthState.Success(
179-
result = null,
180-
user = currentUser,
181-
isNewUser = false
182-
)
192+
AuthState.Idle
183193
}
184-
} else {
185-
AuthState.Idle
194+
trySend(state)
186195
}
187-
trySend(state)
188-
}
189196

190-
// Add listener
191-
auth.addAuthStateListener(authStateListener)
197+
// Add listener
198+
auth.addAuthStateListener(authStateListener)
192199

193-
// Also observe internal state changes
194-
_authStateFlow.value.let { currentState ->
195-
if (currentState !is AuthState.Idle && currentState !is AuthState.Success) {
196-
trySend(currentState)
200+
// Remove listener when flow collection is cancelled
201+
awaitClose {
202+
auth.removeAuthStateListener(authStateListener)
197203
}
198204
}
199205

200-
// Remove listener when flow collection is cancelled
201-
awaitClose {
202-
auth.removeAuthStateListener(authStateListener)
206+
// Also observe internal state changes
207+
return combine(
208+
firebaseAuthFlow,
209+
_authStateFlow
210+
) { firebaseState, internalState ->
211+
// Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.)
212+
if (internalState !is AuthState.Idle) internalState else firebaseState
203213
}
204214
}
205215

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

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class AuthUIConfigurationBuilder {
4444
var tosUrl: String? = null
4545
var privacyPolicyUrl: String? = null
4646
var logo: ImageVector? = null
47-
var actionCodeSettings: ActionCodeSettings? = null
47+
var passwordResetActionCodeSettings: ActionCodeSettings? = null
4848
var isNewEmailAccountsAllowed: Boolean = true
4949
var isDisplayNameRequired: Boolean = true
5050
var isProviderChoiceAlwaysShown: Boolean = false
@@ -85,17 +85,7 @@ class AuthUIConfigurationBuilder {
8585
// Provider specific validations
8686
providers.forEach { provider ->
8787
when (provider) {
88-
is AuthProvider.Email -> {
89-
provider.validate()
90-
91-
if (isAnonymousUpgradeEnabled && provider.isEmailLinkSignInEnabled) {
92-
check(provider.isEmailLinkForceSameDeviceEnabled) {
93-
"You must force the same device flow when using email link sign in " +
94-
"with anonymous user upgrade"
95-
}
96-
}
97-
}
98-
88+
is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled)
9989
is AuthProvider.Phone -> provider.validate()
10090
is AuthProvider.Google -> provider.validate(context)
10191
is AuthProvider.Facebook -> provider.validate(context)
@@ -116,7 +106,7 @@ class AuthUIConfigurationBuilder {
116106
tosUrl = tosUrl,
117107
privacyPolicyUrl = privacyPolicyUrl,
118108
logo = logo,
119-
actionCodeSettings = actionCodeSettings,
109+
passwordResetActionCodeSettings = passwordResetActionCodeSettings,
120110
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
121111
isDisplayNameRequired = isDisplayNameRequired,
122112
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown
@@ -184,9 +174,9 @@ class AuthUIConfiguration(
184174
val logo: ImageVector? = null,
185175

186176
/**
187-
* Configuration for email link sign-in.
177+
* Configuration for sending email reset link.
188178
*/
189-
val actionCodeSettings: ActionCodeSettings? = null,
179+
val passwordResetActionCodeSettings: ActionCodeSettings? = null,
190180

191181
/**
192182
* Allows new email accounts to be created. Defaults to true.

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ abstract class AuthProvider(open val providerId: String) {
174174
/**
175175
* Settings for email link actions.
176176
*/
177-
val actionCodeSettings: ActionCodeSettings?,
177+
val emailLinkActionCodeSettings: ActionCodeSettings?,
178178

179179
/**
180180
* Allows new accounts to be created. Defaults to true.
@@ -202,9 +202,11 @@ abstract class AuthProvider(open val providerId: String) {
202202
val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret")
203203
}
204204

205-
internal fun validate() {
205+
internal fun validate(
206+
isAnonymousUpgradeEnabled: Boolean = false
207+
) {
206208
if (isEmailLinkSignInEnabled) {
207-
val actionCodeSettings = requireNotNull(actionCodeSettings) {
209+
val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) {
208210
"ActionCodeSettings cannot be null when using " +
209211
"email link sign in."
210212
}
@@ -213,6 +215,13 @@ abstract class AuthProvider(open val providerId: String) {
213215
"You must set canHandleCodeInApp in your " +
214216
"ActionCodeSettings to true for Email-Link Sign-in."
215217
}
218+
219+
if (isAnonymousUpgradeEnabled) {
220+
check(isEmailLinkForceSameDeviceEnabled) {
221+
"You must force the same device flow when using email link sign in " +
222+
"with anonymous user upgrade"
223+
}
224+
}
216225
}
217226
}
218227

@@ -221,11 +230,11 @@ abstract class AuthProvider(open val providerId: String) {
221230
sessionId: String,
222231
anonymousUserId: String,
223232
): ActionCodeSettings {
224-
requireNotNull(actionCodeSettings) {
233+
requireNotNull(emailLinkActionCodeSettings) {
225234
"ActionCodeSettings is required for email link sign in"
226235
}
227236

228-
val continueUrl = continueUrl(actionCodeSettings.url) {
237+
val continueUrl = continueUrl(emailLinkActionCodeSettings.url) {
229238
appendSessionId(sessionId)
230239
appendAnonymousUserId(anonymousUserId)
231240
appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled)
@@ -234,12 +243,12 @@ abstract class AuthProvider(open val providerId: String) {
234243

235244
return actionCodeSettings {
236245
url = continueUrl
237-
handleCodeInApp = actionCodeSettings.canHandleCodeInApp()
238-
iosBundleId = actionCodeSettings.iosBundle
246+
handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp()
247+
iosBundleId = emailLinkActionCodeSettings.iosBundle
239248
setAndroidPackageName(
240-
actionCodeSettings.androidPackageName ?: "",
241-
actionCodeSettings.androidInstallApp,
242-
actionCodeSettings.androidMinimumVersion
249+
emailLinkActionCodeSettings.androidPackageName ?: "",
250+
emailLinkActionCodeSettings.androidInstallApp,
251+
emailLinkActionCodeSettings.androidMinimumVersion
243252
)
244253
}
245254
}

0 commit comments

Comments
 (0)