Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion auth/src/main/java/com/firebase/ui/auth/data/CountryData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

package com.firebase.ui.auth.data

import androidx.compose.runtime.saveable.Saver

/**
* Represents country information for phone number authentication.
*
Expand All @@ -39,6 +41,21 @@ data class CountryData(
fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)"
}

/**
* Round-trips [CountryData] through `rememberSaveable` as a positional list of its four fields.
*/
internal val CountryDataSaver: Saver<CountryData, List<String>> = Saver(
save = { listOf(it.name, it.dialCode, it.countryCode, it.flagEmoji) },
restore = { saved ->
CountryData(
name = saved[0],
dialCode = saved[1],
countryCode = saved[2],
flagEmoji = saved[3],
)
},
)

/**
* Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji.
*
Expand All @@ -49,7 +66,7 @@ fun countryCodeToFlagEmoji(countryCode: String): String {
if (countryCode.length != 2) return ""

val uppercaseCode = countryCode.uppercase()
val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A
val baseCodePoint = 0x1F1E6
val charCodeOffset = 'A'.code

val firstChar = uppercaseCode[0].code
Expand Down
47 changes: 37 additions & 10 deletions auth/src/main/java/com/firebase/ui/auth/mfa/SmsEnrollmentHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package com.firebase.ui.auth.mfa

import android.app.Activity
import androidx.compose.runtime.saveable.Saver
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS
import com.google.firebase.auth.FirebaseAuth
Expand Down Expand Up @@ -336,6 +337,33 @@ data class SmsEnrollmentSession(
}
}

/**
* Round-trips [SmsEnrollmentSession] through `rememberSaveable` as a positional list; every field
* it carries is `Parcelable` or a primitive.
*/
internal val SmsEnrollmentSessionSaver: Saver<SmsEnrollmentSession?, List<Any?>> = Saver(
save = { session ->
session?.let {
listOf(
it.verificationId,
it.phoneNumber,
it.forceResendingToken,
it.sentAt,
it.autoVerifiedCredential,
)
}
},
restore = { saved ->
SmsEnrollmentSession(
verificationId = saved[0] as String,
phoneNumber = saved[1] as String,
forceResendingToken = saved[2] as PhoneAuthProvider.ForceResendingToken?,
sentAt = saved[3] as Long,
autoVerifiedCredential = saved[4] as PhoneAuthCredential?,
)
},
)

/**
* Masks the middle digits of a phone number for privacy.
*
Expand All @@ -357,20 +385,19 @@ fun maskPhoneNumber(phoneNumber: String): String {
return phoneNumber
}

// Determine country code length (typically 1-3 digits after +)
val digitsOnly = phoneNumber.substring(1) // Remove +
// Country-code length is a heuristic: NANP (+1) is one digit, most others two.
val digitsOnly = phoneNumber.substring(1)
val countryCodeLength = when {
digitsOnly.length > 10 -> 2 // Likely 2-digit country code
digitsOnly[0] == '1' -> 1 // North America
else -> 2 // Most other countries
digitsOnly.length > 10 -> 2
digitsOnly[0] == '1' -> 1
else -> 2
}

val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include +
// Keep last 3-4 digits visible, with longer numbers showing more
val countryCode = phoneNumber.substring(0, countryCodeLength + 1)
val lastDigitsCount = when {
phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits
phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits
else -> 2 // Short numbers show 2 digits
phoneNumber.length >= 14 -> 4
phoneNumber.length >= 11 -> 3
else -> 2
}
val lastDigits = phoneNumber.takeLast(lastDigitsCount)
val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import com.firebase.ui.auth.ui.components.getRecoveryMessage
import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController
import com.firebase.ui.auth.mfa.MfaChallengeContentState
import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
import com.firebase.ui.auth.mfa.MfaEnrollmentStep
import com.firebase.ui.auth.ui.exposeTestTagsAsResourceIds
import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker
import com.firebase.ui.auth.ui.method_picker.MethodPickerTermsConfiguration
Expand All @@ -97,7 +98,10 @@ import com.firebase.ui.auth.ui.screens.email.isEmailLinkSignInOffered
import com.firebase.ui.auth.ui.screens.email.isEmailSignUpOffered
import com.firebase.ui.auth.ui.screens.email.navigateToEmailStep
import com.firebase.ui.auth.ui.screens.mfa.MfaChallengeScreen
import com.firebase.ui.auth.ui.screens.mfa.MfaEnrollmentScreen
import com.firebase.ui.auth.ui.screens.mfa.exitMfaEnrollment
import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentDestinations
import com.firebase.ui.auth.ui.screens.mfa.mfaEnrollmentStartStep
import com.firebase.ui.auth.ui.screens.mfa.rememberMfaEnrollmentFlowState
import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen
import com.firebase.ui.auth.ui.screens.reauth.CustomReauthContent
Expand Down Expand Up @@ -179,6 +183,8 @@ fun FirebaseAuthScreen(
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
// Above the NavHost, so a step switch cannot dispose it.
val mfaEnrollmentFlowState = rememberMfaEnrollmentFlowState()
Comment thread
demolaf marked this conversation as resolved.
// FirebaseAuthUI only folds ordinary states into an armed request while a drainer is present.
DisposableEffect(authUI) {
authUI.addReauthenticationDrainer()
Expand Down Expand Up @@ -404,7 +410,9 @@ fun FirebaseAuthScreen(
// Inert while armed: this content stays composed beneath the slot.
if (reauthState == null) {
if (configuration.isMfaEnabled) {
navController.navigate(AuthRoute.MfaEnrollment.route)
navController.navigate(
mfaEnrollmentStartStep(mfaConfiguration).route
)
} else {
val exception = AuthException.AuthCancelledException(
message = "Multi-factor authentication is disabled in the configuration. " +
Expand Down Expand Up @@ -445,7 +453,14 @@ fun FirebaseAuthScreen(
onNavigate = { route ->
// Inert while armed: this content stays composed beneath the slot.
if (reauthState == null) {
navController.navigate(route.route)
// MfaEnrollment.route names SelectFactor; one factor skips it.
if (route == AuthRoute.MfaEnrollment) {
navController.navigate(
mfaEnrollmentStartStep(mfaConfiguration).route
)
} else {
navController.navigate(route.route)
}
}
}
)
Expand All @@ -463,28 +478,19 @@ fun FirebaseAuthScreen(
}
}

// As with the phone steps: every declared step registered, all one screen.
AuthRoute.MfaEnrollment.steps.forEach { step ->
composable(step.routePattern) {
val user = authUI.getCurrentUser()
if (user != null) {
MfaEnrollmentScreen(
user = user,
auth = authUI.auth,
configuration = mfaConfiguration,
authConfiguration = configuration,
content = mfaEnrollmentContent,
onComplete = { navController.popBackStack() },
onSkip = { navController.popBackStack() },
onError = { exception ->
onSignInFailure(AuthException.from(exception, stringProvider))
}
)
} else {
navController.popBackStack()
}
mfaEnrollmentDestinations(
navController = navController,
configuration = mfaConfiguration,
authConfiguration = configuration,
authUI = authUI,
flowState = mfaEnrollmentFlowState,
content = mfaEnrollmentContent,
onComplete = { navController.exitMfaEnrollment() },
onSkip = { navController.exitMfaEnrollment() },
onError = { exception ->
onSignInFailure(AuthException.from(exception, stringProvider))
}
}
)

composable(AuthRoute.MfaChallenge.routePattern) {
// Retained for this entry: onSuccess clears pendingResolver, blanking the exit.
Expand Down Expand Up @@ -1103,21 +1109,33 @@ sealed class AuthRoute {
object MfaEnrollment : AuthRoute() {
override val route: String get() = SelectFactor.route

/** One step per screen the enrolment flow walks through. */
sealed class Step(private val id: String) : AuthRoute() {
/**
* One step per screen the enrolment flow walks through. [enrollmentStep] is the
* [MfaEnrollmentStep] the screen renders for this destination.
*/
sealed class Step(
private val id: String,
internal val enrollmentStep: MfaEnrollmentStep,
) : AuthRoute() {
override val route: String get() = id
}

object SelectFactor : Step("auth_mfa_enrollment_select_factor")
object SelectFactor : Step("auth_mfa_enrollment_select_factor", MfaEnrollmentStep.SelectFactor)

object ConfigureSms : Step("auth_mfa_enrollment_configure_sms")
object ConfigureSms : Step("auth_mfa_enrollment_configure_sms", MfaEnrollmentStep.ConfigureSms)

object ConfigureTotp : Step("auth_mfa_enrollment_configure_totp")
object ConfigureTotp : Step("auth_mfa_enrollment_configure_totp", MfaEnrollmentStep.ConfigureTotp)

object VerifyFactor : Step("auth_mfa_enrollment_verify_factor")
object VerifyFactor : Step("auth_mfa_enrollment_verify_factor", MfaEnrollmentStep.VerifyFactor)

internal val steps: List<Step>
get() = listOf(SelectFactor, ConfigureSms, ConfigureTotp, VerifyFactor)

internal fun stepFor(enrollmentStep: MfaEnrollmentStep): Step =
steps.first { it.enrollmentStep == enrollmentStep }

/** Whether [route] — a live `NavDestination.route` — belongs to this flow. */
internal fun isStep(route: String?): Boolean = steps.any { it.routePattern == route }
}

internal companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ internal fun NavHostController.navigateToEmailStep(step: AuthRoute.Email.Step, e
}
}

/** Shown for as long as a redirect out of an unreachable step takes. */
/** Shown for as long as a redirect off a step that cannot render itself takes. */
@Composable
internal fun RedirectingStep() {
Box(
Expand Down
Loading