Skip to content

Commit 10b6dc7

Browse files
authored
feat: Email Provider Integration (#2233)
* 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 * feat: add PasswordResetLinkSent state * 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
1 parent a2ba642 commit 10b6dc7

26 files changed

+2184
-94
lines changed

auth/build.gradle.kts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,13 @@ dependencies {
8585
implementation(Config.Libs.Androidx.materialDesign)
8686
implementation(Config.Libs.Androidx.activity)
8787
implementation(Config.Libs.Androidx.Compose.materialIconsExtended)
88+
implementation(Config.Libs.Androidx.datastorePreferences)
8889
// The new activity result APIs force us to include Fragment 1.3.0
8990
// See https://issuetracker.google.com/issues/152554847
9091
implementation(Config.Libs.Androidx.fragment)
9192
implementation(Config.Libs.Androidx.customTabs)
9293
implementation(Config.Libs.Androidx.constraint)
93-
implementation("androidx.credentials:credentials:1.3.0")
94+
implementation(Config.Libs.Androidx.credentials)
9495
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
9596

9697
implementation(Config.Libs.Androidx.lifecycleExtensions)
@@ -111,12 +112,27 @@ dependencies {
111112

112113
testImplementation(Config.Libs.Test.junit)
113114
testImplementation(Config.Libs.Test.truth)
114-
testImplementation(Config.Libs.Test.mockito)
115115
testImplementation(Config.Libs.Test.core)
116116
testImplementation(Config.Libs.Test.robolectric)
117117
testImplementation(Config.Libs.Test.kotlinReflect)
118118
testImplementation(Config.Libs.Provider.facebook)
119+
testImplementation(Config.Libs.Test.mockitoCore)
120+
testImplementation(Config.Libs.Test.mockitoInline)
121+
testImplementation(Config.Libs.Test.mockitoKotlin)
122+
testImplementation(Config.Libs.Androidx.credentials)
119123
testImplementation(Config.Libs.Test.composeUiTestJunit4)
120124

121125
debugImplementation(project(":internal:lintchecks"))
122126
}
127+
128+
val mockitoAgent by configurations.creating
129+
130+
dependencies {
131+
mockitoAgent(Config.Libs.Test.mockitoCore) {
132+
isTransitive = false
133+
}
134+
}
135+
136+
tasks.withType<Test>().configureEach {
137+
jvmArgs("-javaagent:${mockitoAgent.asPath}")
138+
}

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

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.firebase.ui.auth.compose
1616

17+
import com.firebase.ui.auth.compose.AuthException.Companion.from
1718
import com.google.firebase.FirebaseException
1819
import com.google.firebase.auth.FirebaseAuthException
1920
import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException
@@ -204,6 +205,39 @@ abstract class AuthException(
204205
cause: Throwable? = null
205206
) : AuthException(message, cause)
206207

208+
class InvalidEmailLinkException(
209+
cause: Throwable? = null
210+
) : AuthException("You are are attempting to sign in with an invalid email link", cause)
211+
212+
class EmailLinkWrongDeviceException(
213+
cause: Throwable? = null
214+
) : AuthException("You must open the email link on the same device.", cause)
215+
216+
class EmailLinkCrossDeviceLinkingException(
217+
cause: Throwable? = null
218+
) : AuthException(
219+
"You must determine if you want to continue linking or " +
220+
"complete the sign in", cause
221+
)
222+
223+
class EmailLinkPromptForEmailException(
224+
cause: Throwable? = null
225+
) : AuthException("Please enter your email to continue signing in", cause)
226+
227+
class EmailLinkDifferentAnonymousUserException(
228+
cause: Throwable? = null
229+
) : AuthException(
230+
"The session associated with this sign-in request has either expired or " +
231+
"was cleared", cause
232+
)
233+
234+
class EmailMismatchException(
235+
cause: Throwable? = null
236+
) : AuthException(
237+
"You are are attempting to sign in a different email than previously " +
238+
"provided", cause
239+
)
240+
207241
companion object {
208242
/**
209243
* Creates an appropriate [AuthException] instance from a Firebase authentication exception.
@@ -244,86 +278,111 @@ abstract class AuthException(
244278
cause = firebaseException
245279
)
246280
}
281+
247282
is FirebaseAuthInvalidUserException -> {
248283
when (firebaseException.errorCode) {
249284
"ERROR_USER_NOT_FOUND" -> UserNotFoundException(
250285
message = firebaseException.message ?: "User not found",
251286
cause = firebaseException
252287
)
288+
253289
"ERROR_USER_DISABLED" -> InvalidCredentialsException(
254290
message = firebaseException.message ?: "User account has been disabled",
255291
cause = firebaseException
256292
)
293+
257294
else -> UserNotFoundException(
258295
message = firebaseException.message ?: "User account error",
259296
cause = firebaseException
260297
)
261298
}
262299
}
300+
263301
is FirebaseAuthWeakPasswordException -> {
264302
WeakPasswordException(
265303
message = firebaseException.message ?: "Password is too weak",
266304
cause = firebaseException,
267305
reason = firebaseException.reason
268306
)
269307
}
308+
270309
is FirebaseAuthUserCollisionException -> {
271310
when (firebaseException.errorCode) {
272311
"ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException(
273-
message = firebaseException.message ?: "Email address is already in use",
312+
message = firebaseException.message
313+
?: "Email address is already in use",
274314
cause = firebaseException,
275315
email = firebaseException.email
276316
)
317+
277318
"ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException(
278-
message = firebaseException.message ?: "Account already exists with different credentials",
319+
message = firebaseException.message
320+
?: "Account already exists with different credentials",
279321
cause = firebaseException
280322
)
323+
281324
"ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException(
282-
message = firebaseException.message ?: "Credential is already associated with a different user account",
325+
message = firebaseException.message
326+
?: "Credential is already associated with a different user account",
283327
cause = firebaseException
284328
)
329+
285330
else -> AccountLinkingRequiredException(
286331
message = firebaseException.message ?: "Account collision error",
287332
cause = firebaseException
288333
)
289334
}
290335
}
336+
291337
is FirebaseAuthMultiFactorException -> {
292338
MfaRequiredException(
293-
message = firebaseException.message ?: "Multi-factor authentication required",
339+
message = firebaseException.message
340+
?: "Multi-factor authentication required",
294341
cause = firebaseException
295342
)
296343
}
344+
297345
is FirebaseAuthRecentLoginRequiredException -> {
298346
InvalidCredentialsException(
299-
message = firebaseException.message ?: "Recent login required for this operation",
347+
message = firebaseException.message
348+
?: "Recent login required for this operation",
300349
cause = firebaseException
301350
)
302351
}
352+
303353
is FirebaseAuthException -> {
304354
// Handle FirebaseAuthException and check for specific error codes
305355
when (firebaseException.errorCode) {
306356
"ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException(
307-
message = firebaseException.message ?: "Too many requests. Please try again later",
357+
message = firebaseException.message
358+
?: "Too many requests. Please try again later",
308359
cause = firebaseException
309360
)
361+
310362
else -> UnknownException(
311-
message = firebaseException.message ?: "An unknown authentication error occurred",
363+
message = firebaseException.message
364+
?: "An unknown authentication error occurred",
312365
cause = firebaseException
313366
)
314367
}
315368
}
369+
316370
is FirebaseException -> {
317371
// Handle general Firebase exceptions, which include network errors
318372
NetworkException(
319373
message = firebaseException.message ?: "Network error occurred",
320374
cause = firebaseException
321375
)
322376
}
377+
323378
else -> {
324379
// Check for common cancellation patterns
325-
if (firebaseException.message?.contains("cancelled", ignoreCase = true) == true ||
326-
firebaseException.message?.contains("canceled", ignoreCase = true) == true) {
380+
if (firebaseException.message?.contains(
381+
"cancelled",
382+
ignoreCase = true
383+
) == true ||
384+
firebaseException.message?.contains("canceled", ignoreCase = true) == true
385+
) {
327386
AuthCancelledException(
328387
message = firebaseException.message ?: "Authentication was cancelled",
329388
cause = firebaseException

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
package com.firebase.ui.auth.compose
1616

17+
import com.firebase.ui.auth.compose.AuthState.Companion.Cancelled
18+
import com.firebase.ui.auth.compose.AuthState.Companion.Idle
19+
import com.google.firebase.auth.AuthCredential
1720
import com.google.firebase.auth.AuthResult
1821
import com.google.firebase.auth.FirebaseUser
1922
import com.google.firebase.auth.MultiFactorResolver
@@ -72,8 +75,8 @@ abstract class AuthState private constructor() {
7275
if (this === other) return true
7376
if (other !is Success) return false
7477
return result == other.result &&
75-
user == other.user &&
76-
isNewUser == other.isNewUser
78+
user == other.user &&
79+
isNewUser == other.isNewUser
7780
}
7881

7982
override fun hashCode(): Int {
@@ -101,7 +104,7 @@ abstract class AuthState private constructor() {
101104
if (this === other) return true
102105
if (other !is Error) return false
103106
return exception == other.exception &&
104-
isRecoverable == other.isRecoverable
107+
isRecoverable == other.isRecoverable
105108
}
106109

107110
override fun hashCode(): Int {
@@ -137,7 +140,7 @@ abstract class AuthState private constructor() {
137140
if (this === other) return true
138141
if (other !is RequiresMfa) return false
139142
return resolver == other.resolver &&
140-
hint == other.hint
143+
hint == other.hint
141144
}
142145

143146
override fun hashCode(): Int {
@@ -164,7 +167,7 @@ abstract class AuthState private constructor() {
164167
if (this === other) return true
165168
if (other !is RequiresEmailVerification) return false
166169
return user == other.user &&
167-
email == other.email
170+
email == other.email
168171
}
169172

170173
override fun hashCode(): Int {
@@ -191,7 +194,7 @@ abstract class AuthState private constructor() {
191194
if (this === other) return true
192195
if (other !is RequiresProfileCompletion) return false
193196
return user == other.user &&
194-
missingFields == other.missingFields
197+
missingFields == other.missingFields
195198
}
196199

197200
override fun hashCode(): Int {
@@ -204,6 +207,42 @@ abstract class AuthState private constructor() {
204207
"AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)"
205208
}
206209

210+
/**
211+
* Pending credential for an anonymous upgrade merge conflict.
212+
*
213+
* Emitted when an anonymous user attempts to convert to a permanent account but
214+
* Firebase detects that the target email already belongs to another user. The UI can
215+
* prompt the user to resolve the conflict by signing in with the existing account and
216+
* later linking the stored [pendingCredential].
217+
*/
218+
class MergeConflict(
219+
val pendingCredential: AuthCredential
220+
) : AuthState() {
221+
override fun equals(other: Any?): Boolean {
222+
if (this === other) return true
223+
if (other !is MergeConflict) return false
224+
return pendingCredential == other.pendingCredential
225+
}
226+
227+
override fun hashCode(): Int {
228+
var result = pendingCredential.hashCode()
229+
result = 31 * result + pendingCredential.hashCode()
230+
return result
231+
}
232+
233+
override fun toString(): String =
234+
"AuthState.MergeConflict(pendingCredential=$pendingCredential)"
235+
}
236+
237+
/**
238+
* Password reset link has been sent to the user's email.
239+
*/
240+
class PasswordResetLinkSent : AuthState() {
241+
override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent
242+
override fun hashCode(): Int = javaClass.hashCode()
243+
override fun toString(): String = "AuthState.PasswordResetLinkSent"
244+
}
245+
207246
companion object {
208247
/**
209248
* Creates an Idle state instance.
@@ -219,4 +258,4 @@ abstract class AuthState private constructor() {
219258
@JvmStatic
220259
val Cancelled: Cancelled = Cancelled()
221260
}
222-
}
261+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414

1515
package com.firebase.ui.auth.compose
1616

17+
import android.content.Context
1718
import androidx.annotation.RestrictTo
1819
import com.google.firebase.FirebaseApp
1920
import com.google.firebase.auth.FirebaseAuth
2021
import com.google.firebase.auth.FirebaseAuth.AuthStateListener
2122
import com.google.firebase.auth.FirebaseUser
2223
import com.google.firebase.auth.ktx.auth
2324
import com.google.firebase.ktx.Firebase
24-
import android.content.Context
2525
import kotlinx.coroutines.CancellationException
2626
import kotlinx.coroutines.channels.awaitClose
2727
import kotlinx.coroutines.flow.Flow
@@ -168,7 +168,8 @@ class FirebaseAuthUI private constructor(
168168
// Check if email verification is required
169169
if (!currentUser.isEmailVerified &&
170170
currentUser.email != null &&
171-
currentUser.providerData.any { it.providerId == "password" }) {
171+
currentUser.providerData.any { it.providerId == "password" }
172+
) {
172173
AuthState.RequiresEmailVerification(
173174
user = currentUser,
174175
email = currentUser.email!!
@@ -374,7 +375,7 @@ class FirebaseAuthUI private constructor(
374375
} catch (e: IllegalStateException) {
375376
throw IllegalStateException(
376377
"Default FirebaseApp is not initialized. " +
377-
"Make sure to call FirebaseApp.initializeApp(Context) first.",
378+
"Make sure to call FirebaseApp.initializeApp(Context) first.",
378379
e
379380
)
380381
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
package com.firebase.ui.auth.compose.configuration
1616

1717
import android.content.Context
18-
import java.util.Locale
19-
import com.google.firebase.auth.ActionCodeSettings
2018
import androidx.compose.ui.graphics.vector.ImageVector
19+
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
20+
import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvidersBuilder
21+
import com.firebase.ui.auth.compose.configuration.auth_provider.Provider
2122
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
2223
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
2324
import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
24-
25-
fun actionCodeSettings(block: ActionCodeSettings.Builder.() -> Unit) =
26-
ActionCodeSettings.newBuilder().apply(block).build()
25+
import com.google.firebase.auth.ActionCodeSettings
26+
import java.util.Locale
2727

2828
fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) =
2929
AuthUIConfigurationBuilder().apply(block).build()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr
1818

1919
/**
2020
* An abstract class representing a set of validation rules that can be applied to a password field,
21-
* typically within the [AuthProvider.Email] configuration.
21+
* typically within the [com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider.Email] configuration.
2222
*/
2323
abstract class PasswordRule {
2424
/**

0 commit comments

Comments
 (0)