From 3ef562cfea4152b9f1e94f916e3af3a81917a50a Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 14:25:52 +0100 Subject: [PATCH 001/150] Auth: add new dependencies --- CREDITS.txt | 5 +++++ app/build.gradle.kts | 17 ++++++++++++----- gradle/libs.versions.toml | 12 ++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CREDITS.txt b/CREDITS.txt index c5e88528c1..f37200df54 100644 --- a/CREDITS.txt +++ b/CREDITS.txt @@ -120,6 +120,9 @@ Copyright 2013 Uwe Trottmann truth (https://github.com/google/truth) Copyright 2011 Google, Inc. +ZXing (https://github.com/zxing/zxing) +Copyright ZXing authors + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -134,6 +137,8 @@ limitations under the License. ## Licensed under Android Software Development Kit License +googleid (https://developers.google.com/identity/android-credential-manager) + Google Play Billing (https://developer.android.com/google/play/billing/billing_library_overview) Google Play Services (https://developers.google.com/android/guides/overview) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33fd4af6b0..d8e4729e8e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -213,12 +213,14 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.coordinatorlayout) implementation(libs.androidx.preference) + implementation(libs.androidx.datastore) implementation(libs.androidx.swiperefreshlayout) // Compose implementation(platform(libs.compose)) androidTestImplementation(platform(libs.compose)) implementation(libs.compose.material3) + implementation(libs.compose.material3.icons) // Android Studio Preview support implementation(libs.compose.tooling.preview) debugImplementation(libs.compose.tooling) @@ -226,6 +228,7 @@ dependencies { implementation(libs.androidx.activity.compose) // Optional - Integration with ViewModels implementation(libs.androidx.lifecycle.compose) + implementation(libs.androidx.navigation) // ViewModel and LiveData implementation(libs.androidx.lifecycle.livedata) @@ -273,14 +276,18 @@ dependencies { exclude(group = "org.threeten", module = "threetenbp") // using ThreeTenABP instead } + // Firebase Authentication + implementation(libs.firebase.ui.auth) // temporary + implementation(libs.firebase.auth) + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play) + implementation(libs.googleid) + implementation(libs.play.services.auth) + implementation(libs.zxing) + // Note: can not use Firebase BOM as firebase-ui-auth has not updated in a while // Crashlytics implementation(libs.firebase.crashlytics) - // Firebase Sign-In - implementation(libs.firebase.ui.auth) - // Use compatible later versions of firebase-ui-auth dependencies to get latest fixes. - implementation(libs.firebase.auth) - implementation(libs.play.services.auth) // Amazon Billing // Note: requires to add AppstoreAuthenticationKey.pem into amazon/assets. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9085e08cdd..29201216e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,14 +79,19 @@ material = "com.google.android.material:material:1.13.0" compose = "androidx.compose:compose-bom:2026.02.00" # https://developer.android.com/jetpack/androidx/releases/compose-material3 compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material3-icons = { module = "androidx.compose.material:material-icons-extended" } compose-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +# https://developer.android.com/jetpack/androidx/releases/navigation +androidx-navigation = "androidx.navigation:navigation-compose:2.8.9" # https://developer.android.com/jetpack/androidx/releases/browser androidx-browser = "androidx.browser:browser:1.9.0" androidx-palette = "androidx.palette:palette-ktx:1.0.0" # https://developer.android.com/jetpack/androidx/releases/preference androidx-preference = "androidx.preference:preference-ktx:1.2.1" +# https://developer.android.com/jetpack/androidx/releases/datastore +androidx-datastore = "androidx.datastore:datastore-preferences:1.1.7" # Room # https://developer.android.com/jetpack/androidx/releases/room @@ -100,6 +105,10 @@ androidx-room-testing = "androidx.room:room-testing:2.8.4" androidutils = "com.uwetrottmann.androidutils:androidutils:4.0.0" photoview = "com.github.chrisbanes:PhotoView:2.3.0" +# https://developer.android.com/jetpack/androidx/releases/credentials +androidx-credentials = "androidx.credentials:credentials:1.5.0" +androidx-credentials-play = "androidx.credentials:credentials-play-services-auth:1.5.0" +googleid = "com.google.android.libraries.identity.googleid:googleid:1.1.1" # https://developers.google.com/android/guides/releases # 21.0.0 removes Credentials API used by firebase-ui-auth play-services-auth = "com.google.android.gms:play-services-auth:20.7.0" @@ -109,6 +118,9 @@ firebase-crashlytics = "com.google.firebase:firebase-crashlytics:20.0.3" firebase-auth = "com.google.firebase:firebase-auth:22.3.1" # https://github.com/firebase/FirebaseUI-Android/releases firebase-ui-auth = "com.firebaseui:firebase-ui-auth:8.0.2" +# Note: ZXing is no longer actively developed +# https://github.com/zxing/zxing/releases +zxing = "com.google.zxing:core:3.5.3" # https://github.com/googleapis/google-api-java-client/releases google-api-client = "com.google.api-client:google-api-client:2.6.0" google-api-client-android = "com.google.api-client:google-api-client-android:2.6.0" From c276a907dcb3bb0e328589edec980780552ddeff Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 14:28:13 +0100 Subject: [PATCH 002/150] Auth: copy files from FirebaseUI --- .../seriesguide/backend/auth/AuthException.kt | 475 +++++++ .../backend/auth/AuthFlowController.kt | 274 ++++ .../seriesguide/backend/auth/AuthState.kt | 312 +++++ .../backend/auth/FirebaseAuthActivity.kt | 204 +++ .../backend/auth/FirebaseAuthUI.kt | 584 ++++++++ .../auth/FirebaseUIComposeRegistrar.kt | 37 + .../auth/configuration/AuthUIConfiguration.kt | 207 +++ .../auth/configuration/AuthUITransitions.kt | 35 + .../auth/configuration/MfaConfiguration.kt | 42 + .../backend/auth/configuration/MfaFactor.kt | 33 + .../auth/configuration/PasswordRule.kt | 123 ++ .../AnonymousAuthProvider+FirebaseAuthUI.kt | 129 ++ .../auth_provider/AuthProvider.kt | 1046 +++++++++++++++ .../EmailAuthProvider+FirebaseAuthUI.kt | 1177 +++++++++++++++++ .../FacebookAuthProvider+FirebaseAuthUI.kt | 232 ++++ .../GoogleAuthProvider+FirebaseAuthUI.kt | 266 ++++ .../OAuthProvider+FirebaseAuthUI.kt | 238 ++++ .../PhoneAuthProvider+FirebaseAuthUI.kt | 341 +++++ .../string_provider/AuthUIStringProvider.kt | 545 ++++++++ .../AuthUIStringProviderSample.kt | 59 + .../DefaultAuthUIStringProvider.kt | 497 +++++++ .../auth/configuration/theme/AuthUIAsset.kt | 67 + .../auth/configuration/theme/AuthUITheme.kt | 257 ++++ .../theme/ProviderStyleDefaults.kt | 108 ++ .../validators/EmailValidator.kt | 48 + .../validators/FieldValidationStatus.kt | 24 + .../validators/FieldValidator.kt | 39 + .../validators/GeneralFieldValidator.kt | 52 + .../validators/PasswordValidator.kt | 54 + .../validators/PhoneNumberValidator.kt | 67 + .../validators/VerificationCodeValidator.kt | 51 + .../credentialmanager/PasswordCredential.kt | 26 + .../PasswordCredentialHandler.kt | 200 +++ .../backend/auth/data/Countries.kt | 260 ++++ .../backend/auth/data/CountryData.kt | 62 + .../auth/mfa/MfaChallengeContentState.kt | 115 ++ .../auth/mfa/MfaEnrollmentContentState.kt | 172 +++ .../backend/auth/mfa/MfaEnrollmentStep.kt | 98 ++ .../backend/auth/mfa/MfaErrorMapper.kt | 39 + .../backend/auth/mfa/SmsEnrollmentHandler.kt | 376 ++++++ .../backend/auth/mfa/TotpEnrollmentHandler.kt | 151 +++ .../backend/auth/mfa/TotpSecret.kt | 104 ++ .../auth/ui/components/AuthProviderButton.kt | 379 ++++++ .../auth/ui/components/AuthTextField.kt | 258 ++++ .../auth/ui/components/CountrySelector.kt | 218 +++ .../auth/ui/components/ErrorRecoveryDialog.kt | 237 ++++ .../backend/auth/ui/components/QrCodeImage.kt | 130 ++ .../ui/components/ReauthenticationDialog.kt | 210 +++ .../auth/ui/components/TermsAndPrivacyForm.kt | 75 ++ .../ui/components/TopLevelDialogController.kt | 172 +++ .../components/VerificationCodeInputField.kt | 396 ++++++ .../method_picker/AnnotatedStringResource.kt | 86 ++ .../auth/ui/method_picker/AuthMethodPicker.kt | 260 ++++ .../auth/ui/screens/FirebaseAuthScreen.kt | 861 ++++++++++++ .../auth/ui/screens/MfaChallengeDefaults.kt | 157 +++ .../auth/ui/screens/MfaChallengeScreen.kt | 267 ++++ .../auth/ui/screens/MfaEnrollmentDefaults.kt | 638 +++++++++ .../auth/ui/screens/MfaEnrollmentScreen.kt | 396 ++++++ .../auth/ui/screens/email/EmailAuthScreen.kt | 424 ++++++ .../auth/ui/screens/email/ResetPasswordUI.kt | 225 ++++ .../ui/screens/email/SignInEmailLinkUI.kt | 275 ++++ .../backend/auth/ui/screens/email/SignInUI.kt | 348 +++++ .../backend/auth/ui/screens/email/SignUpUI.kt | 260 ++++ .../ui/screens/phone/EnterPhoneNumberUI.kt | 195 +++ .../screens/phone/EnterVerificationCodeUI.kt | 226 ++++ .../auth/ui/screens/phone/PhoneAuthScreen.kt | 387 ++++++ .../backend/auth/util/ContinueUrlBuilder.kt | 72 + .../backend/auth/util/CountryUtils.kt | 143 ++ .../auth/util/CredentialPersistenceManager.kt | 75 ++ .../backend/auth/util/EmailLinkConstants.kt | 64 + .../backend/auth/util/EmailLinkParser.kt | 99 ++ .../auth/util/EmailLinkPersistenceManager.kt | 138 ++ .../backend/auth/util/PersistenceManager.kt | 73 + .../backend/auth/util/PhoneNumberUtils.kt | 72 + .../backend/auth/util/Preconditions.java | 112 ++ .../backend/auth/util/ProviderAvailability.kt | 38 + .../backend/auth/util/SessionUtils.kt | 25 + .../auth/util/SignInPreferenceManager.kt | 119 ++ 78 files changed, 17336 insertions(+) create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt create mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt new file mode 100644 index 0000000000..46d22f068c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt @@ -0,0 +1,475 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import com.firebase.ui.auth.AuthException.Companion.from +import com.google.firebase.FirebaseException +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FirebaseAuthException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseAuthMultiFactorException +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.FirebaseAuthWeakPasswordException + +/** + * Abstract base class representing all possible authentication exceptions in Firebase Auth UI. + * + * This class provides a unified exception hierarchy for authentication operations, allowing + * for consistent error handling across the entire Auth UI system. + * + * Use the companion object [from] method to create specific exception instances from + * Firebase authentication exceptions. + * + * **Example usage:** + * ```kotlin + * try { + * // Perform authentication operation + * } catch (firebaseException: Exception) { + * val authException = AuthException.from(firebaseException) + * when (authException) { + * is AuthException.NetworkException -> { + * // Handle network error + * } + * is AuthException.InvalidCredentialsException -> { + * // Handle invalid credentials + * } + * // ... handle other exception types + * } + * } + * ``` + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * + * @since 10.0.0 + */ +abstract class AuthException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) { + + /** + * A network error occurred during the authentication operation. + * + * This exception is thrown when there are connectivity issues, timeouts, + * or other network-related problems. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class NetworkException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The provided credentials are not valid. + * + * This exception is thrown when the user provides incorrect login information, + * such as wrong email/password combinations or malformed credentials. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class InvalidCredentialsException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The user account does not exist. + * + * This exception is thrown when attempting to sign in with credentials + * for a user that doesn't exist in the Firebase Auth system. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class UserNotFoundException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The password provided is not strong enough. + * + * This exception is thrown when creating an account or updating a password + * with a password that doesn't meet the security requirements. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * @property reason The specific reason why the password is considered weak + */ + class WeakPasswordException( + message: String, + cause: Throwable? = null, + val reason: String? = null + ) : AuthException(message, cause) + + /** + * An account with the given email already exists. + * + * This exception is thrown when attempting to create a new account with + * an email address that is already registered. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + * @property email The email address that already exists + */ + class EmailAlreadyInUseException( + message: String, + cause: Throwable? = null, + val email: String? = null + ) : AuthException(message, cause) + + /** + * Too many requests have been made to the server. + * + * This exception is thrown when the client has made too many requests + * in a short period and needs to wait before making additional requests. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class TooManyRequestsException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Phone verification is in cooldown period for the same phone number. + * + * This exception is thrown when attempting to verify the same phone number + * again before the cooldown period (timeout) has expired. + * + * @property message The detailed error message + * @property cooldownSeconds The number of seconds remaining in the cooldown period + * @property cause The underlying [Throwable] that caused this exception + */ + class PhoneVerificationCooldownException( + message: String, + val cooldownSeconds: Long, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Multi-Factor Authentication is required to proceed. + * + * This exception is thrown when a user has MFA enabled and needs to + * complete additional authentication steps. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class MfaRequiredException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Account linking is required to complete sign-in. + * + * This exception is thrown when a user tries to sign in with a provider + * that needs to be linked to an existing account. For example, when a user + * tries to sign in with Facebook but an account already exists with that + * email using a different provider (like email/password). + * + * @property message The detailed error message + * @property email The email address that already has an account (optional) + * @property credential The credential that should be linked after signing in (optional) + * @property cause The underlying [Throwable] that caused this exception + */ + class AccountLinkingRequiredException( + message: String, + val email: String? = null, + val credential: AuthCredential? = null, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * Authentication was cancelled by the user. + * + * This exception is thrown when the user cancels an authentication flow, + * such as dismissing a sign-in dialog or backing out of the process. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class AuthCancelledException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * An unknown or unhandled error occurred. + * + * This exception is thrown for errors that don't match any of the specific + * exception types or for unexpected system errors. + * + * @property message The detailed error message + * @property cause The underlying [Throwable] that caused this exception + */ + class UnknownException( + message: String, + cause: Throwable? = null + ) : AuthException(message, cause) + + /** + * The email link used for sign-in is invalid or malformed. + * + * This exception is thrown when the link is not a valid Firebase email link, + * has incorrect format, or is missing required parameters. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class InvalidEmailLinkException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in with an invalid email link", cause) + + /** + * The email link is being used on a different device than where it was requested. + * + * This exception is thrown when `forceSameDevice = true` and the user opens + * the link on a different device than the one used to request it. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkWrongDeviceException( + cause: Throwable? = null + ) : AuthException("You must open the email link on the same device.", cause) + + /** + * Cross-device account linking is required to complete email link sign-in. + * + * This exception is thrown when the email link matches an existing account with + * a social provider (Google/Facebook), and the user needs to sign in with that + * provider to link accounts. + * + * @property providerName The name of the social provider that needs to be linked + * @property emailLink The email link being processed + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkCrossDeviceLinkingException( + val providerName: String? = null, + val emailLink: String? = null, + cause: Throwable? = null + ) : AuthException("You must determine if you want to continue linking or " + + "complete the sign in", cause) + + /** + * User needs to provide their email address to complete email link sign-in. + * + * This exception is thrown when the email link is opened on a different device + * and the email address cannot be determined from stored session data. + * + * @property emailLink The email link to be used after email is provided + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkPromptForEmailException( + cause: Throwable? = null, + val emailLink: String? = null, + ) : AuthException("Please enter your email to continue signing in", cause) + + /** + * Email link sign-in attempted with a different anonymous user than expected. + * + * This exception is thrown when an email link for anonymous account upgrade is + * opened on a device with a different anonymous user session. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailLinkDifferentAnonymousUserException( + cause: Throwable? = null + ) : AuthException("The session associated with this sign-in request has either " + + "expired or was cleared", cause) + + /** + * The email address provided does not match the email link. + * + * This exception is thrown when the user enters an email address that doesn't + * match the email to which the sign-in link was sent. + * + * @property cause The underlying [Throwable] that caused this exception + */ + class EmailMismatchException( + cause: Throwable? = null + ) : AuthException("You are are attempting to sign in a different email " + + "than previously provided", cause) + + companion object { + /** + * Creates an appropriate [AuthException] instance from a Firebase authentication exception. + * + * This method maps known Firebase exception types to their corresponding [AuthException] + * subtypes, providing a consistent exception hierarchy for error handling. + * + * **Mapping:** + * - [FirebaseException] → [NetworkException] (for network-related errors) + * - [FirebaseAuthInvalidCredentialsException] → [InvalidCredentialsException] + * - [FirebaseAuthInvalidUserException] → [UserNotFoundException] + * - [FirebaseAuthWeakPasswordException] → [WeakPasswordException] + * - [FirebaseAuthUserCollisionException] → [EmailAlreadyInUseException] + * - [FirebaseAuthException] with ERROR_TOO_MANY_REQUESTS → [TooManyRequestsException] + * - [FirebaseAuthMultiFactorException] → [MfaRequiredException] + * - Other exceptions → [UnknownException] + * + * **Example:** + * ```kotlin + * try { + * // Firebase auth operation + * } catch (firebaseException: Exception) { + * val authException = AuthException.from(firebaseException) + * handleAuthError(authException) + * } + * ``` + * + * @param firebaseException The Firebase exception to convert + * @return An appropriate [AuthException] subtype + */ + @JvmStatic + fun from(firebaseException: Exception): AuthException { + return when (firebaseException) { + // If already an AuthException, return it directly + is AuthException -> firebaseException + + // Handle specific Firebase Auth exceptions first (before general FirebaseException) + is FirebaseAuthInvalidCredentialsException -> { + InvalidCredentialsException( + message = firebaseException.message ?: "Invalid credentials provided", + cause = firebaseException + ) + } + + is FirebaseAuthInvalidUserException -> { + when (firebaseException.errorCode) { + "ERROR_USER_NOT_FOUND" -> UserNotFoundException( + message = firebaseException.message ?: "User not found", + cause = firebaseException + ) + + "ERROR_USER_DISABLED" -> InvalidCredentialsException( + message = firebaseException.message ?: "User account has been disabled", + cause = firebaseException + ) + + else -> UserNotFoundException( + message = firebaseException.message ?: "User account error", + cause = firebaseException + ) + } + } + + is FirebaseAuthWeakPasswordException -> { + WeakPasswordException( + message = firebaseException.message ?: "Password is too weak", + cause = firebaseException, + reason = firebaseException.reason + ) + } + + is FirebaseAuthUserCollisionException -> { + when (firebaseException.errorCode) { + "ERROR_EMAIL_ALREADY_IN_USE" -> EmailAlreadyInUseException( + message = firebaseException.message + ?: "Email address is already in use", + cause = firebaseException, + email = firebaseException.email + ) + + "ERROR_ACCOUNT_EXISTS_WITH_DIFFERENT_CREDENTIAL" -> AccountLinkingRequiredException( + message = firebaseException.message + ?: "Account already exists with different credentials", + cause = firebaseException + ) + + "ERROR_CREDENTIAL_ALREADY_IN_USE" -> AccountLinkingRequiredException( + message = firebaseException.message + ?: "Credential is already associated with a different user account", + cause = firebaseException + ) + + else -> AccountLinkingRequiredException( + message = firebaseException.message ?: "Account collision error", + cause = firebaseException + ) + } + } + + is FirebaseAuthMultiFactorException -> { + MfaRequiredException( + message = firebaseException.message + ?: "Multi-factor authentication required", + cause = firebaseException + ) + } + + is FirebaseAuthRecentLoginRequiredException -> { + InvalidCredentialsException( + message = firebaseException.message + ?: "Recent login required for this operation", + cause = firebaseException + ) + } + + is FirebaseAuthException -> { + // Handle FirebaseAuthException and check for specific error codes + when (firebaseException.errorCode) { + "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( + message = firebaseException.message + ?: "Too many requests. Please try again later", + cause = firebaseException + ) + + else -> UnknownException( + message = firebaseException.message + ?: "An unknown authentication error occurred", + cause = firebaseException + ) + } + } + + is FirebaseException -> { + // Handle general Firebase exceptions, which include network errors + NetworkException( + message = firebaseException.message ?: "Network error occurred", + cause = firebaseException + ) + } + + else -> { + // Check for common cancellation patterns + if (firebaseException.message?.contains( + "cancelled", + ignoreCase = true + ) == true || + firebaseException.message?.contains("canceled", ignoreCase = true) == true + ) { + AuthCancelledException( + message = firebaseException.message ?: "Authentication was cancelled", + cause = firebaseException + ) + } else { + UnknownException( + message = firebaseException.message ?: "An unknown error occurred", + cause = firebaseException + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt new file mode 100644 index 0000000000..44cdf45aab --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt @@ -0,0 +1,274 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Controller for managing the Firebase authentication flow lifecycle. + * + * This controller provides a lifecycle-safe way to start, monitor, and cancel + * the authentication flow. It handles coroutine lifecycle, state listeners, + * and resource cleanup automatically. + * + * **Usage Pattern:** + * ```kotlin + * class MyActivity : ComponentActivity() { + * private lateinit var authController: AuthFlowController + * + * private val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * // Auth flow completed + * } + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf( + * AuthProvider.Email(), + * AuthProvider.Google(...) + * ) + * } + * + * authController = authUI.createAuthFlow(configuration) + * + * // Observe auth state + * lifecycleScope.launch { + * authController.authStateFlow.collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User signed in successfully + * val user = state.user + * } + * is AuthState.Error -> { + * // Handle error + * } + * is AuthState.Cancelled -> { + * // User cancelled + * } + * else -> {} + * } + * } + * } + * + * // Start auth flow + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * } + * + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * } + * ``` + * + * **Lifecycle Management:** + * - [createIntent] - Generate Intent to start the auth flow Activity + * - [start] - Alternative to launch the flow (for Activity context) + * - [cancel] - Cancel the ongoing auth flow, transitions to [AuthState.Cancelled] + * - [dispose] - Release all resources (coroutines, listeners). Call in onDestroy() + * + * @property authUI The [FirebaseAuthUI] instance managing authentication + * @property configuration The [AuthUIConfiguration] defining the auth flow behavior + * + * @since 10.0.0 + */ +class AuthFlowController internal constructor( + private val authUI: FirebaseAuthUI, + private val configuration: AuthUIConfiguration +) { + + private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) + private val isDisposed = AtomicBoolean(false) + private var stateCollectionJob: Job? = null + + /** + * Flow of [AuthState] changes during the authentication flow. + * + * Subscribe to this flow to observe authentication state changes. + * The flow is backed by the [FirebaseAuthUI.authStateFlow] and will + * emit states like: + * - [AuthState.Idle] - No active authentication + * - [AuthState.Loading] - Authentication in progress + * - [AuthState.Success] - User signed in successfully + * - [AuthState.Error] - Authentication error occurred + * - [AuthState.Cancelled] - User cancelled the flow + * - [AuthState.RequiresMfa] - Multi-factor authentication required + * - [AuthState.RequiresEmailVerification] - Email verification required + */ + val authStateFlow: Flow + get() { + checkNotDisposed() + return authUI.authStateFlow() + } + + /** + * Creates an Intent to launch the Firebase authentication flow. + * + * Use this method with [ActivityResultLauncher] to start the auth flow + * and handle the result in a lifecycle-aware manner. + * + * **Example:** + * ```kotlin + * val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == Activity.RESULT_OK) { + * // Auth flow completed successfully + * } else { + * // Auth flow cancelled or error + * } + * } + * + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * ``` + * + * @param context Android [Context] to create the Intent + * @return [Intent] configured to launch the auth flow Activity + * @throws IllegalStateException if the controller has been disposed + */ + fun createIntent(context: Context): Intent { + checkNotDisposed() + return FirebaseAuthActivity.createIntent(context, configuration) + } + + /** + * Starts the Firebase authentication flow. + * + * This method launches the auth flow Activity from the provided [Activity] context. + * For better lifecycle management, prefer using [createIntent] with + * [ActivityResultLauncher] instead. + * + * **Note:** This method uses [Activity.startActivityForResult] which is deprecated. + * Consider using [createIntent] with the Activity Result API instead. + * + * @param activity The [Activity] to launch from + * @param requestCode Request code for [Activity.onActivityResult] + * @throws IllegalStateException if the controller has been disposed + * + * @see createIntent + */ + @Deprecated( + message = "Use createIntent() with ActivityResultLauncher instead", + replaceWith = ReplaceWith("createIntent(activity)"), + level = DeprecationLevel.WARNING + ) + fun start(activity: Activity, requestCode: Int = RC_SIGN_IN) { + checkNotDisposed() + val intent = createIntent(activity) + activity.startActivityForResult(intent, requestCode) + } + + /** + * Cancels the ongoing authentication flow. + * + * This method transitions the auth state to [AuthState.Cancelled] and + * signals the auth flow to terminate. The auth flow Activity will finish + * and return [Activity.RESULT_CANCELED]. + * + * **Example:** + * ```kotlin + * // User clicked a "Cancel" button + * cancelButton.setOnClickListener { + * authController.cancel() + * } + * ``` + * + * @throws IllegalStateException if the controller has been disposed + */ + fun cancel() { + checkNotDisposed() + authUI.updateAuthState(AuthState.Cancelled) + } + + /** + * Disposes the controller and releases all resources. + * + * This method: + * - Cancels all coroutines in the controller scope + * - Stops listening to auth state changes + * - Marks the controller as disposed + * + * Call this method in your Activity's `onDestroy()` to prevent memory leaks. + * + * **Important:** Once disposed, this controller cannot be reused. Create a new + * controller if you need to start another auth flow. + * + * **Example:** + * ```kotlin + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * ``` + * + * @throws IllegalStateException if already disposed (when called multiple times) + */ + fun dispose() { + if (isDisposed.compareAndSet(false, true)) { + stateCollectionJob?.cancel() + coroutineScope.cancel() + } + } + + /** + * Checks if the controller has been disposed. + * + * @return `true` if disposed, `false` otherwise + */ + fun isDisposed(): Boolean = isDisposed.get() + + private fun checkNotDisposed() { + check(!isDisposed.get()) { + "AuthFlowController has been disposed. Create a new controller to start another auth flow." + } + } + + internal fun startStateCollection() { + if (stateCollectionJob == null || stateCollectionJob?.isActive == false) { + stateCollectionJob = authUI.authStateFlow() + .onEach { state -> + // Optional: Add logging or side effects here + } + .launchIn(coroutineScope) + } + } + + companion object { + /** + * Request code for the sign-in activity result. + * + * Use this constant when calling [start] with `startActivityForResult`. + */ + const val RC_SIGN_IN = 9001 + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt new file mode 100644 index 0000000000..061b33a453 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt @@ -0,0 +1,312 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import com.firebase.ui.auth.AuthState.Companion.Cancelled +import com.firebase.ui.auth.AuthState.Companion.Idle +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider + +/** + * Represents the authentication state in Firebase Auth UI. + * + * This class encapsulates all possible authentication states that can occur during + * the authentication flow, including success, error, and intermediate states. + * + * Use the companion object factory methods or specific subclass constructors to create instances. + * + * @since 10.0.0 + */ +abstract class AuthState private constructor() { + + /** + * Initial state before any authentication operation has been started. + */ + class Idle internal constructor() : AuthState() { + override fun equals(other: Any?): Boolean = other is Idle + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.Idle" + } + + /** + * Authentication operation is in progress. + * + * @property message Optional message describing what is being loaded + */ + class Loading(val message: String? = null) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Loading) return false + return message == other.message + } + + override fun hashCode(): Int = message?.hashCode() ?: 0 + + override fun toString(): String = "AuthState.Loading(message=$message)" + } + + /** + * Authentication completed successfully. + * + * @property result The [AuthResult] containing the authenticated user, may be null if not available + * @property user The authenticated [FirebaseUser] + * @property isNewUser Whether this is a newly created user account + */ + class Success( + val result: AuthResult?, + val user: FirebaseUser, + val isNewUser: Boolean = false + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Success) return false + return result == other.result && + user == other.user && + isNewUser == other.isNewUser + } + + override fun hashCode(): Int { + var result1 = result?.hashCode() ?: 0 + result1 = 31 * result1 + user.hashCode() + result1 = 31 * result1 + isNewUser.hashCode() + return result1 + } + + override fun toString(): String = + "AuthState.Success(result=$result, user=$user, isNewUser=$isNewUser)" + } + + /** + * An error occurred during authentication. + * + * @property exception The [Exception] that occurred + * @property isRecoverable Whether the error can be recovered from + */ + class Error( + val exception: Exception, + val isRecoverable: Boolean = true + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Error) return false + return exception == other.exception && + isRecoverable == other.isRecoverable + } + + override fun hashCode(): Int { + var result = exception.hashCode() + result = 31 * result + isRecoverable.hashCode() + return result + } + + override fun toString(): String = + "AuthState.Error(exception=$exception, isRecoverable=$isRecoverable)" + } + + /** + * Authentication was cancelled by the user. + */ + class Cancelled internal constructor() : AuthState() { + override fun equals(other: Any?): Boolean = other is Cancelled + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.Cancelled" + } + + /** + * Multi-factor authentication is required to complete sign-in. + * + * @property resolver The [MultiFactorResolver] to complete MFA + * @property hint Optional hint about which factor to use + */ + class RequiresMfa( + val resolver: MultiFactorResolver, + val hint: String? = null + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresMfa) return false + return resolver == other.resolver && + hint == other.hint + } + + override fun hashCode(): Int { + var result = resolver.hashCode() + result = 31 * result + (hint?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "AuthState.RequiresMfa(resolver=$resolver, hint=$hint)" + } + + /** + * Email verification is required before the user can access the app. + * + * @property user The [FirebaseUser] who needs to verify their email + * @property email The email address that needs verification + */ + class RequiresEmailVerification( + val user: FirebaseUser, + val email: String + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresEmailVerification) return false + return user == other.user && + email == other.email + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + email.hashCode() + return result + } + + override fun toString(): String = + "AuthState.RequiresEmailVerification(user=$user, email=$email)" + } + + /** + * The user needs to complete their profile information. + * + * @property user The [FirebaseUser] who needs to complete their profile + * @property missingFields List of profile fields that need to be completed + */ + class RequiresProfileCompletion( + val user: FirebaseUser, + val missingFields: List = emptyList() + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RequiresProfileCompletion) return false + return user == other.user && + missingFields == other.missingFields + } + + override fun hashCode(): Int { + var result = user.hashCode() + result = 31 * result + missingFields.hashCode() + return result + } + + override fun toString(): String = + "AuthState.RequiresProfileCompletion(user=$user, missingFields=$missingFields)" + } + + /** + * Password reset link has been sent to the user's email. + */ + class PasswordResetLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is PasswordResetLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.PasswordResetLinkSent" + } + + /** + * Email sign in link has been sent to the user's email. + */ + class EmailSignInLinkSent : AuthState() { + override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent + override fun hashCode(): Int = javaClass.hashCode() + override fun toString(): String = "AuthState.EmailSignInLinkSent" + } + + /** + * Phone number was automatically verified via SMS instant verification. + * + * This state is emitted when Firebase Phone Authentication successfully retrieves + * and verifies the SMS code automatically without user interaction. This happens + * when Google Play services can detect the incoming SMS message. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in the user + * + * @see PhoneNumberVerificationRequired for the manual verification flow + */ + class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SMSAutoVerified) return false + return credential == other.credential + } + + override fun hashCode(): Int { + var result = credential.hashCode() + result = 31 * result + credential.hashCode() + return result + } + + override fun toString(): String = + "AuthState.SMSAutoVerified(credential=$credential)" + } + + /** + * Phone number verification requires manual code entry. + * + * This state is emitted when Firebase Phone Authentication cannot instantly verify + * the phone number and sends an SMS code that the user must manually enter. This is + * the normal flow when automatic SMS retrieval is not available or fails. + * + * **Resending codes:** + * To allow users to resend the verification code (if they didn't receive it), + * call [FirebaseAuthUI.verifyPhoneNumber] again with: + * - `isForceResendingTokenEnabled = true` + * - `forceResendingToken` from this state + * + * @property verificationId The verification ID to use when submitting the code. + * This must be passed to [FirebaseAuthUI.submitVerificationCode]. + * @property forceResendingToken Token that can be used to resend the SMS code if needed + * + */ + class PhoneNumberVerificationRequired( + val verificationId: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken, + ) : AuthState() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PhoneNumberVerificationRequired) return false + return verificationId == other.verificationId && + forceResendingToken == other.forceResendingToken + } + + override fun hashCode(): Int { + var result = verificationId.hashCode() + result = 31 * result + forceResendingToken.hashCode() + return result + } + + override fun toString(): String = + "AuthState.PhoneNumberVerificationRequired(verificationId=$verificationId, " + + "forceResendingToken=$forceResendingToken)" + } + + companion object { + /** + * Creates an Idle state instance. + * @return A new [Idle] state + */ + @JvmStatic + val Idle: Idle = Idle() + + /** + * Creates a Cancelled state instance. + * @return A new [Cancelled] state + */ + @JvmStatic + val Cancelled: Cancelled = Cancelled() + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt new file mode 100644 index 0000000000..168670da1b --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.lifecycleScope +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.util.EmailLinkConstants +import kotlinx.coroutines.launch +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Activity that hosts the Firebase authentication flow UI. + * + * This activity displays the [FirebaseAuthScreen] composable and manages + * the authentication flow lifecycle. It automatically finishes when the user + * signs in successfully or cancels the flow. + * + * **Do not launch this Activity directly.** + * Use [AuthFlowController] to start the auth flow: + * + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf(AuthProvider.Email(), AuthProvider.Google(...)) + * } + * val controller = authUI.createAuthFlow(configuration) + * val intent = controller.createIntent(context) + * launcher.launch(intent) + * ``` + * + * **Result Codes:** + * - [Activity.RESULT_OK] - User signed in successfully + * - [Activity.RESULT_CANCELED] - User cancelled or error occurred + * + * **Result Data:** + * - [EXTRA_USER_ID] - User ID string (when RESULT_OK) + * - [EXTRA_IS_NEW_USER] - Boolean indicating if user is new (when RESULT_OK) + * - [EXTRA_ERROR] - [AuthException] when an error occurs + * + * **Note:** To get the full user object after successful sign-in, use: + * ```kotlin + * FirebaseAuth.getInstance().currentUser + * ``` + * + * @see AuthFlowController + * @see FirebaseAuthScreen + * @since 10.0.0 + */ +class FirebaseAuthActivity : ComponentActivity() { + + private lateinit var authUI: FirebaseAuthUI + private lateinit var configuration: AuthUIConfiguration + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Extract configuration from cache using UUID key + val configKey = intent.getStringExtra(EXTRA_CONFIGURATION_KEY) + configuration = if (configKey != null) { + configurationCache.remove(configKey) + } else { + null + } ?: run { + // Missing configuration, finish with error + setResult(RESULT_CANCELED) + finish() + return + } + + authUI = FirebaseAuthUI.getInstance() + + // Extract email link if present + val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) + + // Observe auth state to automatically finish when done + lifecycleScope.launch { + authUI.authStateFlow().collect { state -> + when (state) { + is AuthState.Success -> { + // User signed in successfully + val resultIntent = Intent().apply { + putExtra(EXTRA_USER_ID, state.user.uid) + putExtra(EXTRA_IS_NEW_USER, state.isNewUser) + } + setResult(RESULT_OK, resultIntent) + finish() + } + is AuthState.Cancelled -> { + // User cancelled the flow + setResult(RESULT_CANCELED) + finish() + } + is AuthState.Error -> { + // Error occurred, finish with error info + val resultIntent = Intent().apply { + putExtra(EXTRA_ERROR, state.exception) + } + setResult(RESULT_CANCELED, resultIntent) + // Don't finish on error, let user see error and retry + } + else -> { + // Other states, keep showing UI + } + } + } + } + + // Set up Compose UI + setContent { + AuthUITheme { + FirebaseAuthScreen( + authUI = authUI, + configuration = configuration, + emailLink = emailLink, + onSignInSuccess = { authResult -> + // State flow will handle finishing + }, + onSignInFailure = { exception -> + // State flow will handle error + }, + onSignInCancelled = { + authUI.updateAuthState(AuthState.Cancelled) + } + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + // Reset auth state when activity is destroyed + if (!isFinishing) { + authUI.updateAuthState(AuthState.Idle) + } + } + + companion object { + private const val EXTRA_CONFIGURATION_KEY = "com.firebase.ui.auth.CONFIGURATION_KEY" + + /** + * Intent extra key for user ID on successful sign-in. + * Use [com.google.firebase.auth.FirebaseAuth.getInstance().currentUser] to get the full user object. + */ + const val EXTRA_USER_ID = "com.firebase.ui.auth.USER_ID" + + /** + * Intent extra key for isNewUser flag on successful sign-in. + */ + const val EXTRA_IS_NEW_USER = "com.firebase.ui.auth.IS_NEW_USER" + + /** + * Intent extra key for [AuthException] on error. + */ + const val EXTRA_ERROR = "com.firebase.ui.auth.ERROR" + + /** + * Cache for configurations passed through Intents. + * Uses UUID keys to avoid serialization issues with Context references. + */ + private val configurationCache = ConcurrentHashMap() + + /** + * Creates an Intent to launch the Firebase authentication flow. + * + * @param context Android [Context] + * @param configuration [AuthUIConfiguration] defining the auth flow + * @return Configured [Intent] to start [FirebaseAuthActivity] + */ + internal fun createIntent( + context: Context, + configuration: AuthUIConfiguration + ): Intent { + val configKey = UUID.randomUUID().toString() + configurationCache[configKey] = configuration + + return Intent(context, FirebaseAuthActivity::class.java).apply { + putExtra(EXTRA_CONFIGURATION_KEY, configKey) + } + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt new file mode 100644 index 0000000000..9f829a37ff --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt @@ -0,0 +1,584 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth + +import android.content.Context +import android.content.Intent +import androidx.annotation.RestrictTo +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook +import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.auth +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.tasks.await +import java.util.concurrent.ConcurrentHashMap + +/** + * The central class that coordinates all authentication operations for Firebase Auth UI Compose. + * This class manages UI state and provides methods for signing in, signing up, and managing + * user accounts. + * + *

Usage

+ * + * **Default app instance:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * ``` + * + * **Custom app instance:** + * ```kotlin + * val customApp = Firebase.app("secondary") + * val authUI = FirebaseAuthUI.getInstance(customApp) + * ``` + * + * **Multi-tenancy with custom auth:** + * ```kotlin + * val customAuth = Firebase.auth(customApp).apply { + * tenantId = "my-tenant-id" + * } + * val authUI = FirebaseAuthUI.create(customApp, customAuth) + * ``` + * + * @property app The [FirebaseApp] instance used for authentication + * @property auth The [FirebaseAuth] instance used for authentication operations + * + * @since 10.0.0 + */ +class FirebaseAuthUI private constructor( + val app: FirebaseApp, + val auth: FirebaseAuth, +) { + + private val _authStateFlow = MutableStateFlow(AuthState.Idle) + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null + + /** + * Checks whether a user is currently signed in. + * + * This method directly mirrors the state of [FirebaseAuth] and returns true if there is + * a currently signed-in user, false otherwise. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * if (authUI.isSignedIn()) { + * // User is signed in + * navigateToHome() + * } else { + * // User is not signed in + * navigateToLogin() + * } + * ``` + * + * @return `true` if a user is signed in, `false` otherwise + */ + fun isSignedIn(): Boolean = auth.currentUser != null + + /** + * Returns the currently signed-in user, or null if no user is signed in. + * + * This method returns the same value as [FirebaseAuth.currentUser] and provides + * direct access to the current user object. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val user = authUI.getCurrentUser() + * user?.let { + * println("User email: ${it.email}") + * println("User ID: ${it.uid}") + * } + * ``` + * + * @return The currently signed-in [FirebaseUser], or `null` if no user is signed in + */ + fun getCurrentUser(): FirebaseUser? = auth.currentUser + + /** + * Returns true if this instance can handle the provided [Intent]. + * + * This mirrors the classic `AuthUI.canHandleIntent` API but uses the [FirebaseAuth] instance + * backing this [FirebaseAuthUI], ensuring custom app/auth configurations are respected. + */ + fun canHandleIntent(intent: Intent?): Boolean { + val link = intent?.data ?: return false + return auth.isSignInWithEmailLink(link.toString()) + } + + /** + * Creates a new authentication flow controller with the specified configuration. + * + * This method returns an [AuthFlowController] that manages the authentication flow + * lifecycle. The controller provides methods to start the flow, monitor its state, + * and clean up resources when done. + * + * **Example with ActivityResultLauncher:** + * ```kotlin + * class MyActivity : ComponentActivity() { + * private lateinit var authController: AuthFlowController + * + * private val authLauncher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == Activity.RESULT_OK) { + * val userId = result.data?.getStringExtra(FirebaseAuthActivity.EXTRA_USER_ID) + * val isNewUser = result.data?.getBooleanExtra( + * FirebaseAuthActivity.EXTRA_IS_NEW_USER, + * false + * ) ?: false + * // Get the full user object + * val user = FirebaseAuth.getInstance().currentUser + * } + * } + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * val configuration = authUIConfiguration { + * providers = listOf( + * AuthProvider.Email(), + * AuthProvider.Google(...) + * ) + * } + * + * authController = authUI.createAuthFlow(configuration) + * + * // Observe auth state + * lifecycleScope.launch { + * authController.authStateFlow.collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User signed in successfully + * } + * is AuthState.Error -> { + * // Handle error + * } + * else -> {} + * } + * } + * } + * + * // Start auth flow + * val intent = authController.createIntent(this) + * authLauncher.launch(intent) + * } + * + * override fun onDestroy() { + * super.onDestroy() + * authController.dispose() + * } + * } + * ``` + * + * @param configuration The [AuthUIConfiguration] defining the auth flow behavior + * @return A new [AuthFlowController] instance + * @see AuthFlowController + * @since 10.0.0 + */ + fun createAuthFlow(configuration: AuthUIConfiguration): AuthFlowController { + return AuthFlowController(this, configuration) + } + + /** + * Returns a [Flow] that emits [AuthState] changes. + * + * This flow observes changes to the authentication state and emits appropriate + * [AuthState] objects. The flow will emit: + * - [AuthState.Idle] when there's no active authentication operation + * - [AuthState.Loading] during authentication operations + * - [AuthState.Success] when a user successfully signs in + * - [AuthState.Error] when an authentication error occurs + * - [AuthState.Cancelled] when authentication is cancelled + * - [AuthState.RequiresMfa] when multi-factor authentication is needed + * - [AuthState.RequiresEmailVerification] when email verification is needed + * + * The flow automatically emits [AuthState.Success] or [AuthState.Idle] based on + * the current authentication state when collection starts. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * lifecycleScope.launch { + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.Success -> { + * // User is signed in + * updateUI(state.user) + * } + * is AuthState.Error -> { + * // Handle error + * showError(state.exception.message) + * } + * is AuthState.Loading -> { + * // Show loading indicator + * showProgressBar() + * } + * // ... handle other states + * } + * } + * } + * ``` + * + * @return A [Flow] of [AuthState] that emits authentication state changes + */ + fun authStateFlow(): Flow { + // Create a flow from FirebaseAuth state listener + val firebaseAuthFlow = callbackFlow { + // Set initial state based on current auth state + val initialState = auth.currentUser?.let { user -> + // Check if email verification is required + if (!user.isEmailVerified && + user.email != null && + user.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification( + user = user, + email = user.email!! + ) + } else { + AuthState.Success(result = null, user = user, isNewUser = false) + } + } ?: AuthState.Idle + + trySend(initialState) + + // Create auth state listener + val authStateListener = AuthStateListener { firebaseAuth -> + val currentUser = firebaseAuth.currentUser + val state = if (currentUser != null) { + // Check if email verification is required + if (!currentUser.isEmailVerified && + currentUser.email != null && + currentUser.providerData.any { it.providerId == "password" } + ) { + AuthState.RequiresEmailVerification( + user = currentUser, + email = currentUser.email!! + ) + } else { + AuthState.Success( + result = null, + user = currentUser, + isNewUser = false + ) + } + } else { + AuthState.Idle + } + trySend(state) + } + + // Add listener + auth.addAuthStateListener(authStateListener) + + // Remove listener when flow collection is cancelled + awaitClose { + auth.removeAuthStateListener(authStateListener) + } + } + + // Also observe internal state changes + return combine( + firebaseAuthFlow, + _authStateFlow + ) { firebaseState, internalState -> + // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.) + if (internalState !is AuthState.Idle) internalState else firebaseState + }.distinctUntilChanged() + } + + /** + * Updates the internal authentication state. + * This method can be used to manually trigger state updates when the Firebase Auth state + * listener doesn't automatically detect changes (e.g., after reloading user properties). + * + * @param state The new [AuthState] to emit + */ + fun updateAuthState(state: AuthState) { + _authStateFlow.value = state + } + + /** + * Signs out the current user and clears authentication state. + * + * This method signs out the user from Firebase Auth and updates the auth state flow + * to reflect the change. The operation is performed asynchronously and will emit + * appropriate states during the process. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * try { + * authUI.signOut(context) + * // User is now signed out + * } catch (e: AuthException) { + * // Handle sign-out error + * when (e) { + * is AuthException.AuthCancelledException -> { + * // User cancelled sign-out + * } + * else -> { + * // Other error occurred + * } + * } + * } + * ``` + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * @since 10.0.0 + */ + suspend fun signOut(context: Context) { + try { + // Update state to loading + updateAuthState(AuthState.Loading("Signing out...")) + + // Sign out from Firebase Auth + auth.signOut() + .also { + signOutFromGoogle(context) + signOutFromFacebook() + } + + // Update state to idle (user signed out) + updateAuthState(AuthState.Idle) + + } catch (e: CancellationException) { + // Handle coroutine cancellation + val cancelledException = AuthException.AuthCancelledException( + message = "Sign-out was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + // Already mapped AuthException, just update state and re-throw + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + // Map to appropriate AuthException + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + /** + * Deletes the current user account and clears authentication state. + * + * This method deletes the current user's account from Firebase Auth. If the user + * hasn't signed in recently, it will throw an exception requiring reauthentication. + * The operation is performed asynchronously and will emit appropriate states during + * the process. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * + * try { + * authUI.delete(context) + * // User account is now deleted + * } catch (e: AuthException.InvalidCredentialsException) { + * // Recent login required - show reauthentication UI + * handleReauthentication() + * } catch (e: AuthException) { + * // Handle other errors + * } + * ``` + * + * @param context The Android [Context] for any required UI operations + * @throws AuthException.InvalidCredentialsException if reauthentication is required + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * @since 10.0.0 + */ + suspend fun delete(context: Context) { + try { + val currentUser = auth.currentUser + ?: throw AuthException.UserNotFoundException( + message = "No user is currently signed in" + ) + + // Update state to loading + updateAuthState(AuthState.Loading("Deleting account...")) + + // Delete the user account + currentUser.delete().await() + + // Update state to idle (user deleted and signed out) + updateAuthState(AuthState.Idle) + + } catch (e: CancellationException) { + // Handle coroutine cancellation + val cancelledException = AuthException.AuthCancelledException( + message = "Account deletion was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + // Already mapped AuthException, just update state and re-throw + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + // Map to appropriate AuthException + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } + } + + companion object { + /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */ + private val instanceCache = ConcurrentHashMap() + + /** Special key for the default app instance to distinguish from named instances. */ + private const val DEFAULT_APP_KEY = "__FIREBASE_UI_DEFAULT__" + + /** + * Returns a cached singleton instance for the default Firebase app. + * + * This method ensures that the same instance is returned for the default app across the + * entire application lifecycle. The instance is lazily created on first access and cached + * for subsequent calls. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val user = authUI.auth.currentUser + * ``` + * + * @return The cached [FirebaseAuthUI] instance for the default app + * @throws IllegalStateException if Firebase has not been initialized. Call + * `FirebaseApp.initializeApp(Context)` before using this method. + */ + @JvmStatic + fun getInstance(): FirebaseAuthUI { + val defaultApp = try { + FirebaseApp.getInstance() + } catch (e: IllegalStateException) { + throw IllegalStateException( + "Default FirebaseApp is not initialized. " + + "Make sure to call FirebaseApp.initializeApp(Context) first.", + e + ) + } + + return instanceCache.getOrPut(DEFAULT_APP_KEY) { + FirebaseAuthUI(defaultApp, Firebase.auth) + } + } + + /** + * Returns a cached instance for a specific Firebase app. + * + * Each [FirebaseApp] gets its own distinct instance that is cached for subsequent calls + * with the same app. This allows for multiple Firebase projects to be used within the + * same application. + * + * **Example:** + * ```kotlin + * val secondaryApp = Firebase.app("secondary") + * val authUI = FirebaseAuthUI.getInstance(secondaryApp) + * ``` + * + * @param app The [FirebaseApp] instance to use + * @return The cached [FirebaseAuthUI] instance for the specified app + */ + @JvmStatic + fun getInstance(app: FirebaseApp): FirebaseAuthUI { + val cacheKey = app.name + return instanceCache.getOrPut(cacheKey) { + FirebaseAuthUI(app, Firebase.auth(app)) + } + } + + /** + * Creates a new instance with custom configuration, useful for multi-tenancy. + * + * This method always returns a new instance and does **not** use caching, allowing for + * custom [FirebaseAuth] configurations such as tenant IDs or custom authentication states. + * Use this when you need fine-grained control over the authentication instance. + * + * **Example - Multi-tenancy:** + * ```kotlin + * val app = Firebase.app("tenant-app") + * val auth = Firebase.auth(app).apply { + * tenantId = "customer-tenant-123" + * } + * val authUI = FirebaseAuthUI.create(app, auth) + * ``` + * + * @param app The [FirebaseApp] instance to use + * @param auth The [FirebaseAuth] instance with custom configuration + * @return A new [FirebaseAuthUI] instance with the provided dependencies + */ + @JvmStatic + fun create(app: FirebaseApp, auth: FirebaseAuth): FirebaseAuthUI { + return FirebaseAuthUI(app, auth) + } + + /** + * Clears all cached instances. This method is intended for testing purposes only. + * + * @suppress This is an internal API and should not be used in production code. + * @RestrictTo RestrictTo.Scope.TESTS + */ + @JvmStatic + @RestrictTo(RestrictTo.Scope.TESTS) + fun clearInstanceCache() { + instanceCache.clear() + } + + /** + * Returns the current number of cached instances. This method is intended for testing + * purposes only. + * + * @return The number of cached [FirebaseAuthUI] instances + * @suppress This is an internal API and should not be used in production code. + * @RestrictTo RestrictTo.Scope.TESTS + */ + @JvmStatic + @RestrictTo(RestrictTo.Scope.TESTS) + internal fun getCacheSize(): Int { + return instanceCache.size + } + + const val UNCONFIGURED_CONFIG_VALUE: String = "CHANGE-ME" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt new file mode 100644 index 0000000000..c985a29af7 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt @@ -0,0 +1,37 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.firebase.ui.auth + +import android.util.Log +import androidx.annotation.Keep +import com.google.firebase.components.Component +import com.google.firebase.components.ComponentRegistrar +import com.google.firebase.platforminfo.LibraryVersionComponent + +/** + * Registers the FirebaseUI-Android Compose library with Firebase Analytics. + * This enables Firebase to track which versions of FirebaseUI are being used. + */ +@Keep +class FirebaseUIComposeRegistrar : ComponentRegistrar { + override fun getComponents(): List> { + Log.d("FirebaseUIRegistrar", "FirebaseUI Compose Registrar initialized: " + + "LIBRARY_NAME: ${BuildConfig.LIBRARY_NAME}, " + + "VERSION_NAME: ${BuildConfig.VERSION_NAME}") + return listOf( + LibraryVersionComponent.create(BuildConfig.LIBRARY_NAME, BuildConfig.VERSION_NAME) + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt new file mode 100644 index 0000000000..3fa7f394bd --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration + +import android.content.Context +import androidx.compose.ui.graphics.vector.ImageVector +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.AuthProvidersBuilder +import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.google.firebase.auth.ActionCodeSettings +import java.util.Locale + +fun authUIConfiguration(block: AuthUIConfigurationBuilder.() -> Unit) = + AuthUIConfigurationBuilder().apply(block).build() + +@DslMarker +annotation class AuthUIConfigurationDsl + +@AuthUIConfigurationDsl +class AuthUIConfigurationBuilder { + var context: Context? = null + private val providers = mutableListOf() + var theme: AuthUITheme? = null + var locale: Locale? = null + var stringProvider: AuthUIStringProvider? = null + var isCredentialManagerEnabled: Boolean = true + var isMfaEnabled: Boolean = true + var isAnonymousUpgradeEnabled: Boolean = false + var tosUrl: String? = null + var privacyPolicyUrl: String? = null + var logo: AuthUIAsset? = null + var passwordResetActionCodeSettings: ActionCodeSettings? = null + var isNewEmailAccountsAllowed: Boolean = true + var isDisplayNameRequired: Boolean = true + var isProviderChoiceAlwaysShown: Boolean = false + var transitions: AuthUITransitions? = null + + fun providers(block: AuthProvidersBuilder.() -> Unit) = + providers.addAll(AuthProvidersBuilder().apply(block).build()) + + internal fun build(): AuthUIConfiguration { + val context = requireNotNull(context) { + "Application context is required" + } + + require(providers.isNotEmpty()) { + "At least one provider must be configured" + } + + // No unsupported providers (allow predefined providers and custom OIDC providers starting with "oidc.") + val supportedProviderIds = Provider.entries.map { it.id }.toSet() + val unknownProviders = providers.filter { provider -> + provider.providerId !in supportedProviderIds && !provider.providerId.startsWith("oidc.") + } + require(unknownProviders.isEmpty()) { + "Unknown providers: ${unknownProviders.joinToString { it.providerId }}" + } + + // Cannot have only anonymous provider + AuthProvider.Anonymous.validate(providers) + + // Check for duplicate providers + val providerIds = providers.map { it.providerId } + val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 } + + require(duplicates.isEmpty()) { + val message = duplicates.keys.joinToString(", ") + throw IllegalArgumentException( + "Each provider can only be set once. Duplicates: $message" + ) + } + + // Provider specific validations + providers.forEach { provider -> + when (provider) { + is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled) + is AuthProvider.Phone -> provider.validate() + is AuthProvider.Google -> provider.validate(context) + is AuthProvider.Facebook -> provider.validate(context) + is AuthProvider.GenericOAuth -> provider.validate() + else -> null + } + } + + return AuthUIConfiguration( + context = context, + providers = providers.toList(), + theme = theme, + locale = locale, + stringProvider = stringProvider ?: DefaultAuthUIStringProvider(context, locale), + isCredentialManagerEnabled = isCredentialManagerEnabled, + isMfaEnabled = isMfaEnabled, + isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled, + tosUrl = tosUrl, + privacyPolicyUrl = privacyPolicyUrl, + logo = logo, + passwordResetActionCodeSettings = passwordResetActionCodeSettings, + isNewEmailAccountsAllowed = isNewEmailAccountsAllowed, + isDisplayNameRequired = isDisplayNameRequired, + isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown, + transitions = transitions + ) + } +} + +/** + * Configuration object for the authentication flow. + */ +class AuthUIConfiguration( + /** + * Application context + */ + val context: Context, + + /** + * The list of enabled authentication providers. + */ + val providers: List = emptyList(), + + /** + * The theming configuration for the UI. If null, inherits from the outer AuthUITheme wrapper + * or defaults to [AuthUITheme.Default] if no wrapper is present. + */ + val theme: AuthUITheme? = null, + + /** + * The locale for internationalization. + */ + val locale: Locale? = null, + + /** + * A custom provider for localized strings. + */ + val stringProvider: AuthUIStringProvider = DefaultAuthUIStringProvider(context, locale), + + /** + * Enables integration with Android's Credential Manager API. Defaults to true. + */ + val isCredentialManagerEnabled: Boolean = true, + + /** + * Enables Multi-Factor Authentication support. Defaults to true. + */ + val isMfaEnabled: Boolean = true, + + /** + * Allows upgrading an anonymous user to a new credential. + */ + val isAnonymousUpgradeEnabled: Boolean = false, + + /** + * The URL for the terms of service. + */ + val tosUrl: String? = null, + + /** + * The URL for the privacy policy. + */ + val privacyPolicyUrl: String? = null, + + /** + * The logo to display on the authentication screens. + */ + val logo: AuthUIAsset? = null, + + /** + * Configuration for sending email reset link. + */ + val passwordResetActionCodeSettings: ActionCodeSettings? = null, + + /** + * Allows new email accounts to be created. Defaults to true. + */ + val isNewEmailAccountsAllowed: Boolean = true, + + /** + * Requires the user to provide a display name on sign-up. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Always shows the provider selection screen, even if only one is enabled. + */ + val isProviderChoiceAlwaysShown: Boolean = false, + + /** + * Custom screen transition animations. + * If null, uses default fade in/out transitions. + */ + val transitions: AuthUITransitions? = null, +) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt new file mode 100644 index 0000000000..b37dc34e19 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.navigation.NavBackStackEntry + +/** + * Container for screen transition animations used in Firebase Auth UI. + * + * @property enterTransition Transition when entering a new screen + * @property exitTransition Transition when exiting current screen + * @property popEnterTransition Transition when returning to previous screen (back navigation) + * @property popExitTransition Transition when exiting during back navigation + */ +data class AuthUITransitions( + val enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null, + val exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null, + val popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition)? = null, + val popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition)? = null, +) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt new file mode 100644 index 0000000000..ed748bfe05 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration + +/** + * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior. + * + * This class controls which MFA factors are available to users, whether enrollment is mandatory, + * and whether recovery codes are generated. + * + * @property allowedFactors List of MFA factors that users are permitted to enroll in. + * Defaults to [MfaFactor.Sms, MfaFactor.Totp]. + * @property requireEnrollment Whether MFA enrollment is mandatory for all users. + * When true, users must enroll in at least one MFA factor. + * Defaults to false. + * @property enableRecoveryCodes Whether to generate and provide recovery codes to users + * after successful MFA enrollment. These codes can be used + * as a backup authentication method. Defaults to true. + */ +class MfaConfiguration( + val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp), + val requireEnrollment: Boolean = false, + val enableRecoveryCodes: Boolean = true +) { + init { + require(allowedFactors.isNotEmpty()) { + "At least one MFA factor must be allowed" + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt new file mode 100644 index 0000000000..472740560d --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration + +/** + * Represents the different Multi-Factor Authentication (MFA) factors that can be used + * for enrollment and verification. + */ +enum class MfaFactor { + /** + * SMS-based authentication factor. + * Users receive a verification code via text message to their registered phone number. + */ + Sms, + + /** + * Time-based One-Time Password (TOTP) authentication factor. + * Users generate verification codes using an authenticator app. + */ + Totp +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt new file mode 100644 index 0000000000..d10f1c8115 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +/** + * An abstract class representing a set of validation rules that can be applied to a password field, + * typically within the [com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Email] configuration. + */ +abstract class PasswordRule { + /** + * Requires the password to have at least a certain number of characters. + */ + class MinimumLength(val value: Int) : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.length >= this@MinimumLength.value + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordTooShort(value) + } + } + + /** + * Requires the password to contain at least one uppercase letter (A-Z). + */ + object RequireUppercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isUpperCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingUppercase + } + } + + /** + * Requires the password to contain at least one lowercase letter (a-z). + */ + object RequireLowercase : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isLowerCase() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingLowercase + } + } + + /** + * Requires the password to contain at least one numeric digit (0-9). + */ + object RequireDigit : PasswordRule() { + override fun isValid(password: String): Boolean { + return password.any { it.isDigit() } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingDigit + } + } + + /** + * Requires the password to contain at least one special character (e.g., !@#$%^&*). + */ + object RequireSpecialCharacter : PasswordRule() { + private val specialCharacters = "!@#$%^&*()_+-=[]{}|;:,.<>?".toSet() + + override fun isValid(password: String): Boolean { + return password.any { it in specialCharacters } + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return stringProvider.passwordMissingSpecialCharacter + } + } + + /** + * Defines a custom validation rule using a regular expression and provides a specific error + * message on failure. + */ + class Custom( + val regex: Regex, + val errorMessage: String + ) : PasswordRule() { + override fun isValid(password: String): Boolean { + return regex.matches(password) + } + + override fun getErrorMessage(stringProvider: AuthUIStringProvider): String { + return errorMessage + } + } + + /** + * Validates whether the given password meets this rule's requirements. + * + * @param password The password to validate + * @return true if the password meets this rule's requirements, false otherwise + */ + internal abstract fun isValid(password: String): Boolean + + /** + * Returns the appropriate error message for this rule when validation fails. + * + * @param stringProvider The string provider for localized error messages + * @return The localized error message for this rule + */ + internal abstract fun getErrorMessage(stringProvider: AuthUIStringProvider): String +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..009765727c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,129 @@ +package com.firebase.ui.auth.configuration.auth_provider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Creates a remembered launcher function for anonymous sign-in. + * + * @return A launcher function that starts the anonymous sign-in flow when invoked + * + * @see signInAnonymously + * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts + */ +@Composable +internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { + val coroutineScope = rememberCoroutineScope() + return remember(this) { + { + coroutineScope.launch { + try { + signInAnonymously() + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in a user anonymously with Firebase Authentication. + * + * This method creates a temporary anonymous user account that can be used for testing + * or as a starting point for users who want to try the app before creating a permanent + * account. Anonymous users can later be upgraded to permanent accounts by linking + * credentials (email/password, social providers, phone, etc.). + * + * **Flow:** + * 1. Updates auth state to loading with "Signing in anonymously..." message + * 2. Calls Firebase Auth's `signInAnonymously()` method + * 3. Updates auth state to idle on success + * 4. Handles cancellation and converts exceptions to [AuthException] types + * + * **Anonymous Account Benefits:** + * - No user data collection required + * - Immediate access to app features + * - Can be upgraded to permanent account later + * - Useful for guest users and app trials + * + * **Account Upgrade:** + * Anonymous accounts can be upgraded to permanent accounts by calling methods like: + * - [signInAndLinkWithCredential] with email/password or social credentials + * - [createOrLinkUserWithEmailAndPassword] for email/password accounts + * - [signInWithPhoneAuthCredential] for phone authentication + * + * **Example: Basic anonymous sign-in** + * ```kotlin + * try { + * firebaseAuthUI.signInAnonymously() + * // User is now signed in anonymously + * // Show app content or prompt for account creation + * } catch (e: AuthException.AuthCancelledException) { + * // User cancelled the sign-in process + * } catch (e: AuthException.NetworkException) { + * // Network error occurred + * } + * ``` + * + * **Example: Anonymous sign-in with upgrade flow** + * ```kotlin + * // Step 1: Sign in anonymously + * firebaseAuthUI.signInAnonymously() + * + * // Step 2: Later, upgrade to permanent account + * try { + * firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * } + * ``` + * + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other authentication errors + * + * @see signInAndLinkWithCredential for upgrading anonymous accounts + * @see createOrLinkUserWithEmailAndPassword for email/password upgrade + * @see signInWithPhoneAuthCredential for phone authentication upgrade + */ +internal suspend fun FirebaseAuthUI.signInAnonymously() { + try { + updateAuthState(AuthState.Loading("Signing in anonymously...")) + auth.signInAnonymously().await() + updateAuthState(AuthState.Idle) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in anonymously was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt new file mode 100644 index 0000000000..5cf392a8c2 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt @@ -0,0 +1,1046 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.auth_provider + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.annotation.RestrictTo +import androidx.compose.ui.graphics.Color +import androidx.core.net.toUri +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.datastore.preferences.core.stringPreferencesKey +import com.facebook.AccessToken +import com.firebase.ui.auth.R +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.AuthUIConfigurationDsl +import com.firebase.ui.auth.configuration.PasswordRule +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.util.ContinueUrlBuilder +import com.firebase.ui.auth.util.PhoneNumberUtils +import com.firebase.ui.auth.util.Preconditions +import com.firebase.ui.auth.util.ProviderAvailability +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.firebase.FirebaseException +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GithubAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.TwitterAuthProvider +import com.google.firebase.auth.UserProfileChangeRequest +import com.google.firebase.auth.actionCodeSettings +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +@AuthUIConfigurationDsl +class AuthProvidersBuilder { + private val providers = mutableListOf() + + fun provider(provider: AuthProvider) { + providers.add(provider) + } + + internal fun build(): List = providers.toList() +} + +/** + * Enum class to represent all possible providers. + */ +internal enum class Provider( + val id: String, + val providerName: String, + val isSocialProvider: Boolean = false, +) { + GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google", isSocialProvider = true), + FACEBOOK(FacebookAuthProvider.PROVIDER_ID, providerName = "Facebook", isSocialProvider = true), + TWITTER(TwitterAuthProvider.PROVIDER_ID, providerName = "Twitter", isSocialProvider = true), + GITHUB(GithubAuthProvider.PROVIDER_ID, providerName = "Github", isSocialProvider = true), + EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"), + PHONE(PhoneAuthProvider.PROVIDER_ID, providerName = "Phone"), + ANONYMOUS("anonymous", providerName = "Anonymous"), + MICROSOFT("microsoft.com", providerName = "Microsoft", isSocialProvider = true), + YAHOO("yahoo.com", providerName = "Yahoo", isSocialProvider = true), + APPLE("apple.com", providerName = "Apple", isSocialProvider = true); + + companion object { + fun fromId(id: String?): Provider? { + return entries.find { it.id == id } + } + } +} + +/** + * Base abstract class for authentication providers. + */ +abstract class AuthProvider(open val providerId: String, open val providerName: String) { + /** + * Base abstract class for OAuth authentication providers with common properties. + */ + abstract class OAuth( + override val providerId: String, + + override val providerName: String, + open val scopes: List = emptyList(), + open val customParameters: Map = emptyMap(), + ) : AuthProvider(providerId = providerId, providerName = providerName) + + /** + * Email/Password authentication provider configuration. + */ + class Email( + /** + * Requires the user to provide a display name. Defaults to true. + */ + val isDisplayNameRequired: Boolean = true, + + /** + * Enables email link sign-in, Defaults to false. + */ + val isEmailLinkSignInEnabled: Boolean = false, + + /** + * Forces email link sign-in to complete on the same device that initiated it. + * + * When enabled, prevents email links from being opened on different devices, + * which is required for security when upgrading anonymous users. Defaults to true. + */ + val isEmailLinkForceSameDeviceEnabled: Boolean = true, + + /** + * Settings for email link actions. + */ + val emailLinkActionCodeSettings: ActionCodeSettings?, + + /** + * Allows new accounts to be created. Defaults to true. + */ + val isNewAccountsAllowed: Boolean = true, + + /** + * The minimum length for a password. Defaults to 6. + */ + val minimumPasswordLength: Int = 6, + + /** + * A list of custom password validation rules. + */ + val passwordValidationRules: List, + ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) { + companion object { + const val SESSION_ID_LENGTH = 10 + val KEY_EMAIL = stringPreferencesKey("com.firebase.ui.auth.data.client.email") + val KEY_PROVIDER = stringPreferencesKey("com.firebase.ui.auth.data.client.provider") + val KEY_ANONYMOUS_USER_ID = + stringPreferencesKey("com.firebase.ui.auth.data.client.auid") + val KEY_SESSION_ID = stringPreferencesKey("com.firebase.ui.auth.data.client.sid") + val KEY_IDP_TOKEN = stringPreferencesKey("com.firebase.ui.auth.data.client.idpToken") + val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret") + } + + internal fun validate(isAnonymousUpgradeEnabled: Boolean = false) { + if (isEmailLinkSignInEnabled) { + val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) { + "ActionCodeSettings cannot be null when using " + + "email link sign in." + } + + check(actionCodeSettings.canHandleCodeInApp()) { + "You must set canHandleCodeInApp in your " + + "ActionCodeSettings to true for Email-Link Sign-in." + } + + if (isAnonymousUpgradeEnabled) { + check(isEmailLinkForceSameDeviceEnabled) { + "You must force the same device flow when using email link sign in " + + "with anonymous user upgrade" + } + } + } + } + + // For Send Email Link + internal fun addSessionInfoToActionCodeSettings( + sessionId: String, + anonymousUserId: String, + credentialForLinking: AuthCredential? = null, + ): ActionCodeSettings { + requireNotNull(emailLinkActionCodeSettings) { + "ActionCodeSettings is required for email link sign in" + } + + val continueUrl = continueUrl(emailLinkActionCodeSettings.url) { + appendSessionId(sessionId) + appendAnonymousUserId(anonymousUserId) + appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled) + // Only append providerId for linking flows (when credentialForLinking is not null) + if (credentialForLinking != null) { + appendProviderId(credentialForLinking.provider) + } + } + + return actionCodeSettings { + url = continueUrl + handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp() + iosBundleId = emailLinkActionCodeSettings.iosBundle + setAndroidPackageName( + emailLinkActionCodeSettings.androidPackageName ?: "", + emailLinkActionCodeSettings.androidInstallApp, + emailLinkActionCodeSettings.androidMinimumVersion + ) + } + } + + // For Sign In With Email Link + internal fun isDifferentDevice( + sessionIdFromLocal: String?, + sessionIdFromLink: String, + ): Boolean { + return sessionIdFromLocal == null || sessionIdFromLocal.isEmpty() + || sessionIdFromLink.isEmpty() + || (sessionIdFromLink != sessionIdFromLocal) + } + + private fun continueUrl(continueUrl: String, block: ContinueUrlBuilder.() -> Unit) = + ContinueUrlBuilder(continueUrl).apply(block).build() + + /** + * An interface to wrap the static `EmailAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(email: String, password: String): AuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential(email: String, password: String): AuthCredential { + return EmailAuthProvider.getCredential(email, password) + } + } + } + + /** + * Phone number authentication provider configuration. + */ + class Phone( + /** + * The phone number in international format. + */ + val defaultNumber: String?, + + /** + * The default country code to pre-select. + */ + val defaultCountryCode: String?, + + /** + * A list of allowed country codes. + */ + val allowedCountries: List?, + + /** + * The expected length of the SMS verification code. Defaults to 6. + */ + val smsCodeLength: Int = 6, + + /** + * The timeout in seconds for receiving the SMS. Defaults to 60L. + */ + val timeout: Long = 60L, + + /** + * Enables instant verification of the phone number. Defaults to true. + */ + val isInstantVerificationEnabled: Boolean = true, + ) : AuthProvider(providerId = Provider.PHONE.id, providerName = Provider.PHONE.providerName) { + /** + * Sealed class representing the result of phone number verification. + * + * Phone verification can complete in two ways: + * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK + * - [NeedsManualVerification]: SMS code was sent, user must manually enter it + */ + internal sealed class VerifyPhoneNumberResult { + /** + * Instant verification succeeded via SMS auto-retrieval. + * + * @property credential The [PhoneAuthCredential] that can be used to sign in + */ + class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult() + + /** + * Instant verification failed, manual code entry required. + * + * @property verificationId The verification ID to use when submitting the code + * @property token Token for resending the verification code + */ + class NeedsManualVerification( + val verificationId: String, + val token: PhoneAuthProvider.ForceResendingToken, + ) : VerifyPhoneNumberResult() + } + + internal fun validate() { + defaultNumber?.let { + check(PhoneNumberUtils.isValid(it)) { + "Invalid phone number: $it" + } + } + + defaultCountryCode?.let { + check(PhoneNumberUtils.isValidIso(it)) { + "Invalid country iso: $it" + } + } + + allowedCountries?.forEach { code -> + check(PhoneNumberUtils.isValidIso(code)) { + "Invalid input: You must provide a valid country iso (alpha-2) " + + "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code" + } + } + } + + /** + * Internal coroutine-based wrapper for Firebase Phone Authentication verification. + * + * This method wraps the callback-based Firebase Phone Auth API into a suspending function + * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks] + * and converts them into a [VerifyPhoneNumberResult]. + * + * **Callback mapping:** + * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified] + * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification] + * - `onVerificationFailed` → throws the exception + * + * This is a private helper method used by [verifyPhoneNumber]. Callers should use + * [verifyPhoneNumber] instead as it handles state management and error handling. + * + * @param auth The [FirebaseAuth] instance to use for verification + * @param phoneNumber The phone number to verify in E.164 format + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * Firebase verifies the phone number for enrolling as a second authentication factor + * instead of primary sign-in. Pass null for standard phone authentication. + * @param forceResendingToken Optional token from previous verification for resending + * + * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed + * @throws FirebaseException if verification fails + */ + internal suspend fun verifyPhoneNumberAwait( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + verifier: Verifier = DefaultVerifier(), + ): VerifyPhoneNumberResult { + return verifier.verifyPhoneNumber( + auth, + activity, + phoneNumber, + timeout, + forceResendingToken, + multiFactorSession, + isInstantVerificationEnabled + ) + } + + /** + * @suppress + */ + internal interface Verifier { + suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): VerifyPhoneNumberResult + } + + /** + * @suppress + */ + internal class DefaultVerifier : Verifier { + override suspend fun verifyPhoneNumber( + auth: FirebaseAuth, + activity: Activity?, + phoneNumber: String, + timeout: Long, + forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + multiFactorSession: MultiFactorSession?, + isInstantVerificationEnabled: Boolean, + ): VerifyPhoneNumberResult { + return suspendCoroutine { continuation -> + val options = PhoneAuthOptions.newBuilder(auth) + .setPhoneNumber(phoneNumber) + .requireSmsValidation(!isInstantVerificationEnabled) + .setTimeout(timeout, TimeUnit.SECONDS) + .setCallbacks(object : + PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: PhoneAuthCredential) { + continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential)) + } + + override fun onVerificationFailed(e: FirebaseException) { + continuation.resumeWithException(e) + } + + override fun onCodeSent( + verificationId: String, + token: PhoneAuthProvider.ForceResendingToken, + ) { + continuation.resume( + VerifyPhoneNumberResult.NeedsManualVerification( + verificationId, + token + ) + ) + } + }) + .apply { + activity?.let { + setActivity(it) + } + forceResendingToken?.let { + setForceResendingToken(it) + } + multiFactorSession?.let { + setMultiFactorSession(it) + } + } + .build() + PhoneAuthProvider.verifyPhoneNumber(options) + } + } + } + + /** + * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable. + * @suppress + */ + internal interface CredentialProvider { + fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential + } + + /** + * The default implementation of [CredentialProvider] that calls the static method. + * @suppress + */ + internal class DefaultCredentialProvider : CredentialProvider { + override fun getCredential( + verificationId: String, + smsCode: String, + ): PhoneAuthCredential { + return PhoneAuthProvider.getCredential(verificationId, smsCode) + } + } + + } + + /** + * Google Sign-In provider configuration. + */ + class Google( + /** + * The list of scopes to request. + */ + override val scopes: List, + + /** + * The OAuth 2.0 client ID for your server. + */ + var serverClientId: String?, + + /** + * Whether to filter by authorized accounts. + * When true, only shows Google accounts that have previously authorized this app. + * Defaults to true, with automatic fallback to false if no authorized accounts found. + */ + val filterByAuthorizedAccounts: Boolean = true, + + /** + * Whether to enable auto-select for single account scenarios. + * When true, automatically selects the account if only one is available. + * Defaults to false for better user control. + */ + val autoSelectEnabled: Boolean = false, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap(), + ) : OAuth( + providerId = Provider.GOOGLE.id, + providerName = Provider.GOOGLE.providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal fun validate(context: Context) { + if (serverClientId == null) { + Preconditions.checkConfigured( + context, + "Check your google-services plugin configuration, the" + + " default_web_client_id string wasn't populated.", + R.string.default_web_client_id + ) + serverClientId = context.getString(R.string.default_web_client_id) + } else { + require(serverClientId!!.isNotBlank()) { + "Server client ID cannot be blank." + } + } + + val hasEmailScope = scopes.contains("email") + if (!hasEmailScope) { + Log.w( + "AuthProvider.Google", + "The scopes do not include 'email'. In most cases this is a mistake!" + ) + } + } + + /** + * Result container for Google Sign-In credential flow. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + data class GoogleSignInResult( + val credential: AuthCredential, + val idToken: String, + val displayName: String?, + val photoUrl: Uri?, + ) + + /** + * An interface to wrap the Authorization API for requesting OAuth scopes. + * @suppress + */ + internal interface AuthorizationProvider { + suspend fun authorize(context: Context, scopes: List) + } + + /** + * The default implementation of [AuthorizationProvider]. + * @suppress + */ + internal class DefaultAuthorizationProvider : AuthorizationProvider { + override suspend fun authorize(context: Context, scopes: List) { + val authorizationRequest = AuthorizationRequest.builder() + .setRequestedScopes(scopes) + .build() + + Identity.getAuthorizationClient(context) + .authorize(authorizationRequest) + .await() + } + } + + /** + * An interface to wrap the Credential Manager flow for Google Sign-In. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + interface CredentialManagerProvider { + suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): GoogleSignInResult + + suspend fun clearCredentialState( + context: Context, + credentialManager: CredentialManager, + ) + } + + /** + * The default implementation of [CredentialManagerProvider]. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + class DefaultCredentialManagerProvider : CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean, + ): GoogleSignInResult { + val googleIdOption = GetGoogleIdOption.Builder() + .setServerClientId(serverClientId) + .setFilterByAuthorizedAccounts(filterByAuthorizedAccounts) + .setAutoSelectEnabled(autoSelectEnabled) + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + val result = credentialManager.getCredential(context, request) + val googleIdTokenCredential = + GoogleIdTokenCredential.createFrom(result.credential.data) + val credential = + GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) + + return GoogleSignInResult( + credential = credential, + idToken = googleIdTokenCredential.idToken, + displayName = googleIdTokenCredential.displayName, + photoUrl = googleIdTokenCredential.profilePictureUri, + ) + } + + override suspend fun clearCredentialState( + context: Context, + credentialManager: CredentialManager, + ) { + credentialManager.clearCredentialState(ClearCredentialStateRequest()) + } + } + } + + /** + * Facebook Login provider configuration. + */ + class Facebook( + /** + * The list of scopes (permissions) to request. Defaults to email and public_profile. + */ + override val scopes: List = listOf("email", "public_profile"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map = emptyMap(), + ) : OAuth( + providerId = Provider.FACEBOOK.id, + providerName = Provider.FACEBOOK.providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal fun validate(context: Context) { + if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { + throw RuntimeException( + "Facebook provider cannot be configured " + + "without dependency. Did you forget to add " + + "'com.facebook.android:facebook-login:VERSION' dependency?" + ) + } + + Preconditions.checkConfigured( + context, + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_application_id` string to your strings.xml", + R.string.facebook_application_id + ) + + Preconditions.checkConfigured( + context, + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_login_protocol_scheme` string to your strings.xml", + R.string.facebook_login_protocol_scheme + ) + + Preconditions.checkConfigured( + context, + "Facebook provider unconfigured. Make sure to " + + "add a `facebook_client_token` string to your strings.xml", + R.string.facebook_client_token + ) + } + + /** + * An interface to wrap Facebook LoginManager and credential operations to make them testable. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + interface LoginManagerProvider { + fun getCredential(token: String): AuthCredential + fun logOut() + } + + /** + * The default implementation of [LoginManagerProvider]. + * @suppress + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + class DefaultLoginManagerProvider : LoginManagerProvider { + override fun getCredential(token: String): AuthCredential { + return FacebookAuthProvider.getCredential(token) + } + + override fun logOut() { + com.facebook.login.LoginManager.getInstance().logOut() + } + } + + /** + * Internal data class to hold Facebook profile information. + */ + internal class FacebookProfileData( + val displayName: String?, + val email: String?, + val photoUrl: Uri?, + ) + + /** + * Fetches user profile data from Facebook Graph API. + * + * @param accessToken The Facebook access token + * @return FacebookProfileData containing user's display name, email, and photo URL + */ + internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? { + return suspendCancellableCoroutine { continuation -> + val request = + com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response -> + try { + val error = response?.error + if (error != null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API error: ${error.errorMessage}" + ) + continuation.resume(null) + return@newMeRequest + } + + if (jsonObject == null) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Graph API returned null response" + ) + continuation.resume(null) + return@newMeRequest + } + + val name = jsonObject.optString("name") + val email = jsonObject.optString("email") + + // Extract photo URL from picture object + val photoUrl = try { + jsonObject.optJSONObject("picture") + ?.optJSONObject("data") + ?.optString("url") + ?.takeIf { it.isNotEmpty() }?.toUri() + } catch (e: Exception) { + Log.w( + "FirebaseAuthUI.signInWithFacebook", + "Error parsing photo URL", + e + ) + null + } + + Log.d( + "FirebaseAuthUI.signInWithFacebook", + "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}" + ) + + continuation.resume( + FacebookProfileData( + displayName = name, + email = email, + photoUrl = photoUrl + ) + ) + } catch (e: Exception) { + Log.e( + "FirebaseAuthUI.signInWithFacebook", + "Error processing Graph API response", + e + ) + continuation.resume(null) + } + } + + // Request specific fields: id, name, email, and picture + val parameters = android.os.Bundle().apply { + putString("fields", "id,name,email,picture") + } + request.parameters = parameters + request.executeAsync() + } + } + } + + /** + * Twitter/X authentication provider configuration. + */ + class Twitter( + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.TWITTER.id, + providerName = Provider.TWITTER.providerName, + customParameters = customParameters + ) + + /** + * Github authentication provider configuration. + */ + class Github( + /** + * The list of scopes to request. Defaults to user:email. + */ + override val scopes: List = listOf("user:email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.GITHUB.id, + providerName = Provider.GITHUB.providerName, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Microsoft authentication provider configuration. + */ + class Microsoft( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * The tenant ID for Azure Active Directory. + */ + val tenant: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.MICROSOFT.id, + providerName = Provider.MICROSOFT.providerName, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Yahoo authentication provider configuration. + */ + class Yahoo( + /** + * The list of scopes to request. Defaults to openid, profile, email. + */ + override val scopes: List = listOf("openid", "profile", "email"), + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.YAHOO.id, + providerName = Provider.YAHOO.providerName, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Apple Sign-In provider configuration. + */ + class Apple( + /** + * The list of scopes to request. Defaults to name and email. + */ + override val scopes: List = listOf("name", "email"), + + /** + * The locale for the sign-in page. + */ + val locale: String?, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + ) : OAuth( + providerId = Provider.APPLE.id, + providerName = Provider.APPLE.providerName, + scopes = scopes, + customParameters = customParameters + ) + + /** + * Anonymous authentication provider. It has no configurable properties. + */ + object Anonymous : AuthProvider( + providerId = Provider.ANONYMOUS.id, + providerName = Provider.ANONYMOUS.providerName + ) { + internal fun validate(providers: List) { + if (providers.size == 1 && providers.first() is Anonymous) { + throw IllegalStateException( + "Sign in as guest cannot be the only sign in method. " + + "In this case, sign the user in anonymously your self; no UI is needed." + ) + } + } + } + + /** + * A generic OAuth provider for any unsupported provider. + */ + class GenericOAuth( + /** + * The provider name. + */ + override val providerName: String, + + /** + * The provider ID as configured in the Firebase console. + */ + override val providerId: String, + + /** + * The list of scopes to request. + */ + override val scopes: List, + + /** + * A map of custom OAuth parameters. + */ + override val customParameters: Map, + + /** + * The text to display on the provider button. + */ + val buttonLabel: String, + + /** + * An optional icon for the provider button. + */ + val buttonIcon: AuthUIAsset?, + + /** + * An optional background color for the provider button. + */ + val buttonColor: Color?, + + /** + * An optional content color for the provider button. + */ + val contentColor: Color?, + ) : OAuth( + providerId = providerId, + providerName = providerName, + scopes = scopes, + customParameters = customParameters + ) { + internal fun validate() { + require(providerId.isNotBlank()) { + "Provider ID cannot be null or empty" + } + + require(buttonLabel.isNotBlank()) { + "Button label cannot be null or empty" + } + } + } + + companion object { + internal fun canUpgradeAnonymous(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean { + val currentUser = auth.currentUser + return config.isAnonymousUpgradeEnabled + && currentUser != null + && currentUser.isAnonymous + } + + /** + * Merges profile information (display name and photo URL) with the current user's profile. + * + * This method updates the user's profile only if the current profile is incomplete + * (missing display name or photo URL). This prevents overwriting existing profile data. + * + * **Use case:** + * After creating a new user account or linking credentials, update the profile with + * information from the sign-up form or social provider. + * + * @param auth The [FirebaseAuth] instance + * @param displayName The display name to set (if current is empty) + * @param photoUri The photo URL to set (if current is null) + * + * **Note:** This operation always succeeds to minimize login interruptions. + * Failures are logged but don't prevent sign-in completion. + */ + internal suspend fun mergeProfile( + auth: FirebaseAuth, + displayName: String?, + photoUri: Uri?, + ) { + try { + val currentUser = auth.currentUser ?: return + + // Only update if current profile is incomplete + val currentDisplayName = currentUser.displayName + val currentPhotoUrl = currentUser.photoUrl + + if (!currentDisplayName.isNullOrEmpty() && currentPhotoUrl != null) { + // Profile is complete, no need to update + return + } + + // Build profile update with provided values + val nameToSet = + if (currentDisplayName.isNullOrEmpty()) displayName else currentDisplayName + val photoToSet = currentPhotoUrl ?: photoUri + + if (nameToSet != null || photoToSet != null) { + val profileUpdates = UserProfileChangeRequest.Builder() + .setDisplayName(nameToSet) + .setPhotoUri(photoToSet) + .build() + + currentUser.updateProfile(profileUpdates).await() + } + } catch (e: Exception) { + // Log error but don't throw - profile update failure shouldn't prevent sign-in + Log.e("AuthProvider.Email", "Error updating profile", e) + } + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..8d4bae6d12 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,1177 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.auth_provider + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.firebase.ui.auth.R +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile +import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler +import com.firebase.ui.auth.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.EmailLinkParser +import com.firebase.ui.auth.util.PersistenceManager +import com.firebase.ui.auth.util.SessionUtils +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ActionCodeSettings +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthMultiFactorException +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + +private const val TAG = "EmailAuthProvider" + +/** + * Creates an email/password account or links the credential to an anonymous user. + * + * Mirrors the legacy email sign-up handler: validates password strength, validates custom + * password rules, checks if new accounts are allowed, chooses between + * `createUserWithEmailAndPassword` and `linkWithCredential`, merges the supplied display name + * into the Firebase profile, and throws [AuthException.AccountLinkingRequiredException] when + * anonymous upgrade encounters an existing account for the email. + * + * **Flow:** + * 1. Check if new accounts are allowed (for non-upgrade flows) + * 2. Validate password length against [AuthProvider.Email.minimumPasswordLength] + * 3. Validate password against custom [AuthProvider.Email.passwordValidationRules] + * 4. If upgrading anonymous user: link credential to existing anonymous account + * 5. Otherwise: create new account with `createUserWithEmailAndPassword` + * 6. Merge display name into user profile + * + * @param context Android [Context] for localized strings + * @param config Auth UI configuration describing provider settings + * @param provider Email provider configuration + * @param name Optional display name collected during sign-up + * @param email Email address for the new account + * @param password Password for the new account + * + * @return [AuthResult] containing the newly created or linked user, or null if failed + * + * @throws AuthException.UserNotFoundException if new accounts are not allowed + * @throws AuthException.WeakPasswordException if the password fails validation rules + * @throws AuthException.InvalidCredentialsException if the email or password is invalid + * @throws AuthException.EmailAlreadyInUseException if the email already exists + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-up** + * ```kotlin + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "John Doe", + * email = "john@example.com", + * password = "SecurePass123!" + * ) + * // User account created successfully + * } catch (e: AuthException.WeakPasswordException) { + * // Password doesn't meet validation rules + * } catch (e: AuthException.EmailAlreadyInUseException) { + * // Email already exists - redirect to sign-in + * } + * ``` + * + * **Example: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * try { + * val result = firebaseAuthUI.createOrLinkUserWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * name = "Jane Smith", + * email = "jane@example.com", + * password = "MyPassword456" + * ) + * // Anonymous account upgraded to permanent email/password account + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Email already exists - show account linking UI + * // User needs to sign in with existing account to link + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + name: String?, + email: String, + password: String, + credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(), +): AuthResult? { + val canUpgrade = canUpgradeAnonymous(config, auth) + val pendingCredential = + if (canUpgrade) credentialProvider.getCredential(email, password) else null + + try { + // Check if new accounts are allowed (only for non-upgrade flows) + if (!canUpgrade && !provider.isNewAccountsAllowed) { + throw AuthException.UserNotFoundException( + message = context.getString(R.string.fui_error_email_does_not_exist) + ) + } + + // Validate minimum password length + if (password.length < provider.minimumPasswordLength) { + throw AuthException.InvalidCredentialsException( + message = context.getString(R.string.fui_error_password_too_short) + .format(provider.minimumPasswordLength) + ) + } + + // Validate password against custom rules + for (rule in provider.passwordValidationRules) { + if (!rule.isValid(password)) { + throw AuthException.WeakPasswordException( + message = rule.getErrorMessage(config.stringProvider), + reason = "Password does not meet custom validation rules" + ) + } + } + + updateAuthState(AuthState.Loading("Creating user...")) + val result = if (canUpgrade) { + auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await() + } else { + auth.createUserWithEmailAndPassword(email, password).await() + }.also { authResult -> + authResult?.user?.let { + // Merge display name into profile (photoUri is always null for email/password) + mergeProfile(auth, name, null) + } + } + + // Save credentials to Credential Manager if enabled + if (config.isCredentialManagerEnabled) { + try { + val credentialHandler = PasswordCredentialHandler(context) + credentialHandler.savePassword(email, password) + Log.d(TAG, "Password credential saved successfully for: $email") + } catch (e: PasswordCredentialCancelledException) { + // User cancelled - this is fine, don't break the auth flow + Log.d(TAG, "User cancelled credential save for: $email") + } catch (e: PasswordCredentialException) { + // Failed to save - log but don't break the auth flow + Log.w(TAG, "Failed to save password credential for: $email", e) + } + } + + // Save sign-in preference for "Continue as..." feature + if (result != null) { + try { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = "password", + identifier = email + ) + Log.d(TAG, "Sign-in preference saved for: $email") + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + Log.w(TAG, "Failed to save sign-in preference for: $email", e) + } + } + + updateAuthState(AuthState.Idle) + return result + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: email already exists + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account.", + email = e.email ?: email, + credential = if (canUpgrade) { + e.updatedCredential ?: pendingCredential + } else { + null + }, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Create or link user with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with email and password, optionally linking a social credential. + * + * This method handles both normal sign-in and anonymous upgrade flows. In anonymous upgrade + * scenarios, it validates credentials in a scratch auth instance before throwing + * [AuthException.AccountLinkingRequiredException]. + * + * **Flow:** + * 1. If anonymous upgrade: + * - Create scratch auth instance to validate credential + * - If linking social provider: sign in with email, then link social credential (safe link) + * - Otherwise: just validate email credential + * - Throw [AuthException.AccountLinkingRequiredException] after successful validation + * 2. If normal sign-in: + * - Sign in with email/password + * - If credential provided: link it and merge profile + * + * @param context Android [Context] for creating scratch auth instance + * @param config Auth UI configuration describing provider settings + * @param email Email address for sign-in + * @param password Password for sign-in + * @param credentialForLinking Optional social provider credential to link after sign-in + * + * @return [AuthResult] containing the signed-in user, or null if validation-only (anonymous upgrade) + * + * @throws AuthException.InvalidCredentialsException if email or password is incorrect + * @throws AuthException.UserNotFoundException if the user doesn't exist + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException for network-related failures + * + * **Example: Normal sign-in** + * ```kotlin + * try { + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123" + * ) + * // User signed in successfully + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong password + * } + * ``` + * + * **Example: Sign-in with social credential linking** + * ```kotlin + * // User tried to sign in with Google, but account exists with email/password + * // Prompt for password, then link Google credential + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * + * val result = firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * password = "password123", + * credentialForLinking = googleCredential + * ) + * // User signed in with email/password AND Google is now linked + * // Profile updated with Google display name and photo + * ``` + * + * **Example: Anonymous upgrade validation** + * ```kotlin + * // User is anonymous, wants to upgrade with existing email/password account + * try { + * firebaseAuthUI.signInWithEmailAndPassword( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "existing@example.com", + * password = "password123" + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Account linking required - UI shows account linking screen + * // User needs to sign in with existing account to link anonymous account + * } + * ``` + */ +internal suspend fun FirebaseAuthUI.signInWithEmailAndPassword( + context: Context, + config: AuthUIConfiguration, + email: String, + password: String, + credentialForLinking: AuthCredential? = null, + skipCredentialSave: Boolean = false, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in...")) + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade flow: validate credential in scratch auth + val credentialToValidate = EmailAuthProvider.getCredential(email, password) + + // Check if we're linking a social provider credential + val isSocialProvider = credentialForLinking != null && + (Provider.fromId(credentialForLinking.provider)?.isSocialProvider ?: false) + + // Create scratch auth instance to avoid losing anonymous user state + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + if (isSocialProvider) { + // Safe link: sign in with email, then link social credential + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .user?.linkWithCredential(credentialForLinking)?.await() + .also { + // Throw AccountLinkingRequiredException after successful validation + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } else { + // Just validate the email credential + // No linking for non-federated IDPs + authExplicitlyForValidation + .signInWithCredential(credentialToValidate).await() + .also { + // Throw AccountLinkingRequiredException after successful validation + // Account exists and user is anonymous - needs to link accounts + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = credentialToValidate, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } + } else { + // Normal sign-in + auth.signInWithEmailAndPassword(email, password).await() + .let { result -> + // If there's a credential to link, link it after sign-in + if (credentialForLinking != null) { + val linkResult = result.user + ?.linkWithCredential(credentialForLinking) + ?.await() + + // Merge profile from social provider + linkResult?.user?.let { user -> + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + + linkResult ?: result + } else { + result + } + } + }.also { result -> + // Save credentials to Credential Manager if enabled + // Skip if user signed in with a retrieved credential (already saved) + if (config.isCredentialManagerEnabled && result != null && !skipCredentialSave) { + try { + val credentialHandler = PasswordCredentialHandler(context) + credentialHandler.savePassword(email, password) + Log.d(TAG, "Password credential saved successfully for: $email") + } catch (e: PasswordCredentialCancelledException) { + // User cancelled - this is fine, don't break the auth flow + Log.d(TAG, "User cancelled credential save for: $email") + } catch (e: PasswordCredentialException) { + // Failed to save - log but don't break the auth flow + Log.w(TAG, "Failed to save password credential for: $email", e) + } + } + + // Save sign-in preference for "Continue as..." feature + if (result != null) { + try { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = "password", + identifier = email + ) + Log.d(TAG, "Sign-in preference saved for: $email") + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + Log.w(TAG, "Failed to save sign-in preference for: $email", e) + } + } + + updateAuthState(AuthState.Idle) + } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email and password was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in with a credential or links it to an existing anonymous user. + * + * This method handles both normal sign-in and anonymous upgrade flows. After successful + * authentication, it merges profile information (display name and photo URL) into the + * Firebase user profile if provided. + * + * **Flow:** + * 1. Check if user is anonymous and upgrade is enabled + * 2. If yes: Link credential to anonymous user + * 3. If no: Sign in with credential + * 4. Merge profile information (name, photo) into Firebase user + * 5. Handle collision exceptions by throwing [AuthException.AccountLinkingRequiredException] + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [AuthCredential] to use for authentication. Can be from any provider. + * @param displayName Optional display name from the provider to merge into the user profile + * @param photoUrl Optional photo URL from the provider to merge into the user profile + * + * @return [AuthResult] containing the authenticated user + * + * @throws AuthException.InvalidCredentialsException if credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if linking and email is already in use + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * + * **Example: Google Sign-In** + * ```kotlin + * val googleCredential = GoogleAuthProvider.getCredential(idToken, null) + * val displayName = "John Doe" // From Google profile + * val photoUrl = Uri.parse("https://...") // From Google profile + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = googleCredential, + * displayName = displayName, + * photoUrl = photoUrl + * ) + * // User signed in with Google AND profile updated with Google data + * ``` + * + * **Example: Phone Auth** + * ```kotlin + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * // User signed in with phone number + * ``` + * + * **Example: Phone Auth with Collision (Anonymous Upgrade)** + * ```kotlin + * // User is currently anonymous, trying to link a phone number + * val phoneCredential = PhoneAuthProvider.getCredential(verificationId, code) + * + * try { + * firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Phone number already exists on another account + * // Account linking required - UI can show account linking screen + * // User needs to sign in with existing account to link + * } + * ``` + * + * **Example: Email Link Sign-In** + * ```kotlin + * val emailLinkCredential = EmailAuthProvider.getCredentialWithLink( + * email = "user@example.com", + * emailLink = emailLink + * ) + * + * val result = firebaseAuthUI.signInAndLinkWithCredential( + * config = authUIConfig, + * credential = emailLinkCredential + * ) + * // User signed in with email link (passwordless) + * ``` + */ +internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential( + config: AuthUIConfiguration, + credential: AuthCredential, + provider: AuthProvider? = null, + displayName: String? = null, + photoUrl: Uri? = null, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in user...")) + return if (canUpgradeAnonymous(config, auth)) { + auth.currentUser?.linkWithCredential(credential)?.await() + } else { + auth.signInWithCredential(credential).await() + }.also { result -> + // Merge profile information from the provider + result?.user?.let { + mergeProfile(auth, displayName, photoUrl) + } + updateAuthState(AuthState.Idle) + } + } catch (e: FirebaseAuthMultiFactorException) { + // MFA required - extract resolver and update state + val resolver = e.resolver + val hint = resolver.hints.firstOrNull()?.displayName + updateAuthState(AuthState.RequiresMfa(resolver, hint)) + return null + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: account already exists with different sign-in method + // Create AccountLinkingRequiredException with credential for linking + val email = e.email + val credentialForException = if (canUpgradeAnonymous(config, auth)) { + // For anonymous upgrade, use the updated credential from the exception + e.updatedCredential ?: credential + } else { + // For non-anonymous, use the original credential + credential + } + + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider?.providerName ?: "this provider"} account.", + email = email, + credential = credentialForException, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in and link with credential was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Sends a passwordless sign-in link to the specified email address. + * + * This method initiates the email-link (passwordless) authentication flow by sending + * an email containing a magic link. The link includes session information for validation + * and security. + * + * **How it works:** + * 1. Generates a unique session ID for same-device validation + * 2. Retrieves anonymous user ID if upgrading anonymous account + * 3. Enriches the [ActionCodeSettings] URL with session data (session ID, anonymous user ID, force same-device flag) + * 4. Sends the email via [com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail] + * 5. Saves session data to DataStore for validation when the user clicks the link + * 6. User receives email with a magic link containing the session information + * 7. When user clicks link, app opens via deep link and calls [signInWithEmailLink] to complete authentication + * + * **Account Linking Support:** + * If a user tries to sign in with a social provider (Google, Facebook) but an email link + * account already exists with that email, the social provider implementation should: + * 1. Catch the [FirebaseAuthUserCollisionException] from the sign-in attempt + * 2. Call [EmailLinkPersistenceManager.default.saveCredentialForLinking] with the provider tokens + * 3. Call this method to send the email link + * 4. When [signInWithEmailLink] completes, it automatically retrieves and links the saved credential + * + * **Session Security:** + * - **Session ID**: Random 10-character string for same-device validation + * - **Anonymous User ID**: Stored if upgrading anonymous account to prevent account hijacking + * - **Force Same Device**: Can be configured via [AuthProvider.Email.isEmailLinkForceSameDeviceEnabled] + * - All session data is validated in [signInWithEmailLink] before completing authentication + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with [ActionCodeSettings] + * @param email The email address to send the sign-in link to + * @param credentialForLinking Optional [AuthCredential] from a social provider to link after email sign-in. + * If provided, the credential is saved to DataStore and automatically linked + * when [signInWithEmailLink] completes. Used for account linking flows. + * + * @throws AuthException.InvalidCredentialsException if email is invalid + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws IllegalStateException if ActionCodeSettings is not configured + * + * **Example 1: Basic email link sign-in** + * ```kotlin + * // Send the email link + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Show "Check your email" UI to user + * + * // Later, when user clicks the link in their email: + * // (In your deep link handling Activity) + * val emailLink = intent.data.toString() + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com", + * emailLink = emailLink + * ) + * // User is now signed in + * ``` + * + * **Example 2: Anonymous user upgrade** + * ```kotlin + * // User is currently signed in anonymously + * // Send email link to upgrade anonymous account to permanent email account + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "user@example.com" + * ) + * // Session includes anonymous user ID for validation + * // When user clicks link, anonymous account is upgraded to permanent account + * ``` + * + * **Example 3: Social provider linking** + * ```kotlin + * try { + * // Try to sign in with Google + * authUI.signInWithGoogle(...) + * } catch (e: FirebaseAuthUserCollisionException) { + * // Email already exists with email-link provider + * val googleCredential = e.updatedCredential + * + * // Save credential for linking + * EmailLinkPersistenceManager.default.saveCredentialForLinking( + * context = context, + * providerType = "google.com", + * idToken = (googleCredential as GoogleAuthCredential).idToken, + * accessToken = null + * ) + * + * // Send email link with credential + * firebaseAuthUI.sendSignInLinkToEmail( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = e.email!!, + * credentialForLinking = googleCredential + * ) + * // When user clicks link and signs in, Google is automatically linked + * } + * ``` + * + * @see signInWithEmailLink + * @see EmailLinkPersistenceManager + * @see com.google.firebase.auth.FirebaseAuth.sendSignInLinkToEmail + */ +internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + credentialForLinking: AuthCredential?, + persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, +) { + try { + updateAuthState(AuthState.Loading("Sending sign in email link...")) + + // Get anonymousUserId if can upgrade anonymously else default to empty string. + // NOTE: check for empty string instead of null to validate anonymous user ID matches + // when sign in from email link + val anonymousUserId = + if (canUpgradeAnonymous(config, auth)) (auth.currentUser?.uid + ?: "") else "" + + // Generate sessionId + val sessionId = + SessionUtils.generateRandomAlphaNumericString(AuthProvider.Email.SESSION_ID_LENGTH) + + // Modify actionCodeSettings Url to include sessionId, anonymousUserId, force same + // device flag + val updatedActionCodeSettings = + provider.addSessionInfoToActionCodeSettings( + sessionId = sessionId, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + + auth.sendSignInLinkToEmail(email, updatedActionCodeSettings).await() + + // Save Email to dataStore for use in signInWithEmailLink + persistenceManager.saveEmail(context, email, sessionId, anonymousUserId) + + updateAuthState(AuthState.EmailSignInLinkSent()) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send sign in link to email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user using an email link (passwordless authentication). + * + * This method completes the email link sign-in flow after the user clicks the magic link + * sent to their email. It validates the link, extracts session information, and either + * signs in the user normally or upgrades an anonymous account based on configuration. + * + * **Flow:** + * 1. User receives email with magic link + * 2. User clicks link, app opens via deep link + * 3. Activity extracts emailLink from Intent.data + * 4. This method validates and completes sign-in + * + * **Same-Device Flow:** + * - Email is retrieved from DataStore automatically + * - Session ID from link matches stored session ID + * - User is signed in immediately without additional input + * + * **Cross-Device Flow:** + * - Session ID from link doesn't match (or no local session exists) + * - If [email] is empty: throws [AuthException.EmailLinkPromptForEmailException] + * - User must provide their email address + * - Call this method again with user-provided email to complete sign-in + * + * @param context Android [Context] for DataStore access + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Email] configuration with email-link settings + * @param email The email address of the user. On same-device, retrieved from DataStore. + * On cross-device first call, pass empty string to trigger validation. + * On cross-device second call, pass user-provided email. + * @param emailLink The complete deep link URL received from the Intent. + * @param persistenceManager Optional [PersistenceManager] for testing. Defaults to [EmailLinkPersistenceManager.default] + * + * This URL contains: + * - Firebase action code (oobCode) for authentication + * - Session ID (ui_sid) for same-device validation + * - Anonymous user ID (ui_auid) if upgrading anonymous account + * - Force same-device flag (ui_sd) for security enforcement + * - Provider ID (ui_pid) if linking social provider credential + * + * Example: + * `https://yourapp.page.link/__/auth/action?oobCode=ABC123&continueUrl=https://yourapp.com?ui_sid=123456&ui_auid=anon-uid` + * + * @return [AuthResult] containing the signed-in user, or null if cross-device validation is required + * + * @throws AuthException.InvalidEmailLinkException if the email link is invalid or expired + * @throws AuthException.EmailLinkPromptForEmailException if cross-device and email is empty + * @throws AuthException.EmailLinkWrongDeviceException if force same-device is enabled on different device + * @throws AuthException.EmailLinkCrossDeviceLinkingException if trying to link provider on different device + * @throws AuthException.EmailLinkDifferentAnonymousUserException if anonymous user ID doesn't match + * @throws AuthException.EmailMismatchException if email is empty on same-device flow + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Same-device sign-in (automatic)** + * ```kotlin + * // In your deep link handler Activity: + * val emailLink = intent.data.toString() + * val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email + * + * if (savedEmail != null) { + * // Same device - email and session are stored + * val result = firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = savedEmail, + * emailLink = emailLink + * ) + * // User is signed in automatically + * } + * ``` + * + * **Example 2: Cross-device sign-in (with email prompt)** + * ```kotlin + * // First call with empty email to validate link + * try { + * firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = "", // Empty email on different device + * emailLink = emailLink + * ) + * } catch (e: AuthException.EmailLinkPromptForEmailException) { + * // Show dialog asking user to enter their email + * val userEmail = showEmailInputDialog() + * + * // Second call with user-provided email + * val result = firebaseAuthUI.signInWithEmailLink( + * context = context, + * config = authUIConfig, + * provider = emailProvider, + * email = userEmail, // User provided email + * emailLink = emailLink + * ) + * // User is now signed in + * } + * ``` + * + * @see sendSignInLinkToEmail for sending the initial email link + * @see EmailLinkPersistenceManager for session data management + */ +internal suspend fun FirebaseAuthUI.signInWithEmailLink( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Email, + email: String, + emailLink: String, + persistenceManager: PersistenceManager = EmailLinkPersistenceManager.default, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with email link...")) + + // Validate link format + if (!auth.isSignInWithEmailLink(emailLink)) { + throw AuthException.InvalidEmailLinkException() + } + + // Parse email link for session data + val parser = EmailLinkParser(emailLink) + val sessionIdFromLink = parser.sessionId + val anonymousUserIdFromLink = parser.anonymousUserId + val oobCode = parser.oobCode + val providerIdFromLink = parser.providerId + val isEmailLinkForceSameDeviceEnabled = parser.forceSameDeviceBit + + // Retrieve stored session record from DataStore + val sessionRecord = persistenceManager.retrieveSessionRecord(context) + val storedSessionId = sessionRecord?.sessionId + + // Check if this is a different device flow + val isDifferentDevice = provider.isDifferentDevice( + sessionIdFromLocal = storedSessionId, + sessionIdFromLink = sessionIdFromLink ?: "" // Convert null to empty string to match legacy behavior + ) + + if (isDifferentDevice) { + // Handle cross-device flow + // Session ID must always be present in the link + if (sessionIdFromLink.isNullOrEmpty()) { + val exception = AuthException.InvalidEmailLinkException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // These scenarios require same-device flow + if (isEmailLinkForceSameDeviceEnabled || !anonymousUserIdFromLink.isNullOrEmpty()) { + val exception = AuthException.EmailLinkWrongDeviceException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // If we have no SessionRecord/there is a session ID mismatch, this means that we were + // not the ones to send the link. The only way forward is to prompt the user for their + // email before continuing the flow. We should only do that after validating the link. + // However, if email is already provided (cross-device with user input), skip validation + if (email.isEmpty()) { + handleDifferentDeviceErrorFlow(oobCode, providerIdFromLink, emailLink) + return null + } + // Email provided - validate it and continue with normal flow + } + + // Validate email is not empty (same-device flow only) + if (email.isEmpty()) { + throw AuthException.EmailMismatchException() + } + + // Validate anonymous user ID matches (same-device flow) + if (!anonymousUserIdFromLink.isNullOrEmpty()) { + val currentUser = auth.currentUser + if (currentUser == null + || !currentUser.isAnonymous + || currentUser.uid != anonymousUserIdFromLink + ) { + val exception = AuthException.EmailLinkDifferentAnonymousUserException() + updateAuthState(AuthState.Error(exception)) + throw exception + } + } + + // Get credential for linking from session record + val storedCredentialForLink = sessionRecord?.credentialForLinking + val emailLinkCredential = EmailAuthProvider.getCredentialWithLink(email, emailLink) + + val result = if (storedCredentialForLink == null) { + // Normal Flow: Just sign in with email link + handleEmailLinkNormalFlow(config, emailLinkCredential) + } else { + // Linking Flow: Sign in with email link, then link the social credential + handleEmailLinkCredentialLinkingFlow( + context = context, + config = config, + email = email, + emailLinkCredential = emailLinkCredential, + storedCredentialForLink = storedCredentialForLink, + ) + } + // Clear DataStore after success + persistenceManager.clear(context) + updateAuthState(AuthState.Idle) + return result + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with email link was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +private suspend fun FirebaseAuthUI.handleDifferentDeviceErrorFlow( + oobCode: String, + providerIdFromLink: String?, + emailLink: String +) { + // Validate the action code + try { + auth.checkActionCode(oobCode).await() + } catch (e: Exception) { + // Invalid action code + val exception = AuthException.InvalidEmailLinkException(cause = e) + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // If there's a provider ID, this is a linking flow which can't be done cross-device + if (!providerIdFromLink.isNullOrEmpty()) { + val providerNameForMessage = + Provider.fromId(providerIdFromLink)?.providerName ?: providerIdFromLink + val exception = AuthException.EmailLinkCrossDeviceLinkingException( + providerName = providerNameForMessage, + emailLink = emailLink + ) + updateAuthState(AuthState.Error(exception)) + throw exception + } + + // Link is valid but we need the user to provide their email + val exception = AuthException.EmailLinkPromptForEmailException( + cause = null, + emailLink = emailLink + ) + updateAuthState(AuthState.Error(exception)) + throw exception +} + +private suspend fun FirebaseAuthUI.handleEmailLinkNormalFlow( + config: AuthUIConfiguration, + emailLinkCredential: AuthCredential, +): AuthResult? { + return signInAndLinkWithCredential(config, emailLinkCredential) +} + +private suspend fun FirebaseAuthUI.handleEmailLinkCredentialLinkingFlow( + context: Context, + config: AuthUIConfiguration, + email: String, + emailLinkCredential: AuthCredential, + storedCredentialForLink: AuthCredential, +): AuthResult? { + return if (canUpgradeAnonymous(config, auth)) { + // Anonymous upgrade: Use safe link pattern with scratch auth + val appExplicitlyForValidation = FirebaseApp.initializeApp( + context, + auth.app.options, + "FUIAuthScratchApp_${System.currentTimeMillis()}" + ) + val authExplicitlyForValidation = FirebaseAuth + .getInstance(appExplicitlyForValidation) + + // Safe link: Validate that both credentials can be linked + authExplicitlyForValidation + .signInWithCredential(emailLinkCredential).await() + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + // If safe link succeeds, throw AccountLinkingRequiredException for UI to handle + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with this email. " + + "Please sign in with your existing account to upgrade your anonymous account.", + email = email, + credential = storedCredentialForLink, + cause = null + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } + } else { + // Non-upgrade: Sign in with email link, then link social credential + auth.signInWithCredential(emailLinkCredential).await() + // Link the social credential + .user?.linkWithCredential(storedCredentialForLink)?.await() + .also { result -> + result?.user?.let { user -> + // Merge profile from the linked social credential + mergeProfile( + auth, + user.displayName, + user.photoUrl + ) + } + } + } +} + +/** + * Sends a password reset email to the specified email address. + * + * This method initiates the "forgot password" flow by sending an email to the user + * with a link to reset their password. The user will receive an email from Firebase + * containing a link that allows them to set a new password for their account. + * + * **Flow:** + * 1. Validate the email address exists in Firebase Auth + * 2. Send password reset email to the user + * 3. Emit [AuthState.PasswordResetLinkSent] state + * 4. User clicks link in email to reset password + * 5. User is redirected to Firebase-hosted password reset page (or custom URL if configured) + * + * **Error Handling:** + * - If the email doesn't exist: throws [AuthException.UserNotFoundException] + * - If the email is invalid: throws [AuthException.InvalidCredentialsException] + * - If network error occurs: throws [AuthException.NetworkException] + * + * @param email The email address to send the password reset email to + * @param actionCodeSettings Optional [ActionCodeSettings] to configure the password reset link. + * Use this to customize the continue URL, dynamic link domain, and other settings. + * + * @throws AuthException.UserNotFoundException if no account exists with this email + * @throws AuthException.InvalidCredentialsException if the email format is invalid + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.UnknownException for other errors + * + * **Example 1: Basic password reset** + * ```kotlin + * try { + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com" + * ) + * // Show success message: "Password reset email sent to $email" + * } catch (e: AuthException.UserNotFoundException) { + * // Show error: "No account exists with this email" + * } catch (e: AuthException.InvalidCredentialsException) { + * // Show error: "Invalid email address" + * } + * ``` + * + * **Example 2: Custom password reset with ActionCodeSettings** + * ```kotlin + * val actionCodeSettings = ActionCodeSettings.newBuilder() + * .setUrl("https://myapp.com/resetPassword") // Continue URL after reset + * .setHandleCodeInApp(false) // Use Firebase-hosted reset page + * .setAndroidPackageName( + * "com.myapp", + * true, // Install if not available + * null // Minimum version + * ) + * .build() + * + * firebaseAuthUI.sendPasswordResetEmail( + * email = "user@example.com", + * actionCodeSettings = actionCodeSettings + * ) + * // User receives email with custom continue URL + * ``` + * + * @see com.google.firebase.auth.ActionCodeSettings + */ +internal suspend fun FirebaseAuthUI.sendPasswordResetEmail( + email: String, + actionCodeSettings: ActionCodeSettings? = null, +) { + try { + updateAuthState(AuthState.Loading("Sending password reset email...")) + auth.sendPasswordResetEmail(email, actionCodeSettings).await() + updateAuthState(AuthState.PasswordResetLinkSent()) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Send password reset email was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..28ef456368 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.auth_provider + +import android.content.Context +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.facebook.AccessToken +import com.facebook.CallbackManager +import com.facebook.FacebookCallback +import com.facebook.FacebookException +import com.facebook.login.LoginManager +import com.facebook.login.LoginResult +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.SignInPreferenceManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Creates a remembered launcher function for Facebook sign-in. + * + * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles + * profile data fetching, Firebase credential creation, anonymous account upgrades, and account + * linking when an email collision occurs. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider + * + * @return A launcher function that starts the Facebook sign-in flow when invoked + * + * @see signInWithFacebook + */ +@Composable +internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + val callbackManager = remember { CallbackManager.Factory.create() } + val loginManager = LoginManager.getInstance() + + val launcher = rememberLauncherForActivityResult( + loginManager.createLogInActivityResultContract( + callbackManager, + null + ), + onResult = {}, + ) + + DisposableEffect(Unit) { + loginManager.registerCallback( + callbackManager, + object : FacebookCallback { + override fun onSuccess(result: LoginResult) { + coroutineScope.launch { + try { + signInWithFacebook( + context = context, + config = config, + provider = provider, + accessToken = result.accessToken, + ) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + + override fun onCancel() { + updateAuthState(AuthState.Idle) + } + + override fun onError(error: FacebookException) { + Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) + val authException = AuthException.from(error) + updateAuthState( + AuthState.Error( + authException + ) + ) + } + }) + + onDispose { loginManager.unregisterCallback(callbackManager) } + } + + return { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + launcher.launch(provider.scopes) + } +} + +/** + * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential. + * + * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in + * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential + * for linking and throwing [AuthException.AccountLinkingRequiredException]. + * + * @param context Android context for DataStore access when saving credentials for linking + * @param config The [AuthUIConfiguration] containing authentication settings + * @param provider The [AuthProvider.Facebook] configuration + * @param accessToken The Facebook [AccessToken] from successful login + * @param credentialProvider Creates Firebase credentials from Facebook tokens + * + * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email + * @throws AuthException.AuthCancelledException if the coroutine is cancelled + * @throws AuthException.NetworkException if a network error occurs + * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid + * + * @see rememberSignInWithFacebookLauncher + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithFacebook( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Facebook, + accessToken: AccessToken, + credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), +) { + try { + updateAuthState( + AuthState.Loading("Signing in with facebook...") + ) + val profileData = provider.fetchFacebookProfile(accessToken) + val credential = credentialProvider.getCredential(accessToken.token) + signInAndLinkWithCredential( + config = config, + credential = credential, + provider = provider, + displayName = profileData?.displayName, + photoUrl = profileData?.photoUrl, + ) + + // Save sign-in preference for "Continue as..." feature + try { + val user = auth.currentUser + val identifier = user?.email + if (identifier != null) { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = provider.providerId, + identifier = identifier + ) + android.util.Log.d("FacebookAuthProvider", "Sign-in preference saved for: $identifier") + } + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + android.util.Log.w("FacebookAuthProvider", "Failed to save sign-in preference", e) + } + } catch (e: AuthException.AccountLinkingRequiredException) { + // Account collision occurred - save Facebook credential for linking after email link sign-in + // This happens when a user tries to sign in with Facebook but an email link account exists + EmailLinkPersistenceManager.default.saveCredentialForLinking( + context = context, + providerType = provider.providerId, + idToken = null, + accessToken = accessToken.token + ) + + // Re-throw to let UI handle the account linking flow + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: FacebookException) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with facebook was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs out the current user from Facebook. + * + * Invokes Facebook's LoginManager to log out the user from their Facebook session. + * This method silently catches and ignores any exceptions that may occur during the + * logout process to ensure the sign-out flow continues even if Facebook logout fails. + * + * This is typically called as part of the overall sign-out flow when a user signs out + * from Firebase Authentication. + */ +internal fun FirebaseAuthUI.signOutFromFacebook( + loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), +) { + try { + if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return + (testLoginManagerProvider ?: loginManagerProvider).logOut() + } catch (e: Exception) { + Log.e("FacebookAuthProvider", "Error during Facebook sign out", e) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..4d18cb0a91 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,266 @@ +package com.firebase.ui.auth.configuration.auth_provider + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Creates a remembered callback for Google Sign-In that can be invoked from UI components. + * + * This Composable function returns a lambda that, when invoked, initiates the Google Sign-In + * flow using [signInWithGoogle]. The callback is stable across recompositions and automatically + * handles coroutine scoping and error state management. + * + * **Usage:** + * ```kotlin + * val onSignInWithGoogle = authUI.rememberGoogleSignInHandler( + * context = context, + * config = configuration, + * provider = googleProvider + * ) + * + * Button(onClick = onSignInWithGoogle) { + * Text("Sign in with Google") + * } + * ``` + * + * **Error Handling:** + * - Catches all exceptions and converts them to [AuthException] + * - Automatically updates [AuthState.Error] on failures + * - Logs errors for debugging purposes + * + * @param context Android context for Credential Manager + * @param config Authentication UI configuration + * @param provider Google provider configuration with server client ID and optional scopes + * @return A callback function that initiates Google Sign-In when invoked + * + * @see signInWithGoogle + * @see AuthProvider.Google + */ +@Composable +internal fun FirebaseAuthUI.rememberGoogleSignInHandler( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Google, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + return remember(this) { + { + coroutineScope.launch { + try { + signInWithGoogle(context, config, provider) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in with Google using Credential Manager and optionally requests OAuth scopes. + * + * This function implements Google Sign-In using Android's Credential Manager API with + * comprehensive error handling. + * + * **Flow:** + * 1. If [AuthProvider.Google.scopes] are specified, requests OAuth authorization first + * 2. Attempts sign-in using Credential Manager + * 3. Creates Firebase credential and calls [signInAndLinkWithCredential] + * + * **Scopes Behavior:** + * - If [AuthProvider.Google.scopes] is not empty, requests OAuth authorization before sign-in + * - Basic profile, email, and ID token are always included automatically + * - Scopes are requested using the AuthorizationClient API + * + * **Error Handling:** + * - [GoogleIdTokenParsingException]: Library version mismatch + * - [NoCredentialException]: No Google accounts on device + * - [GetCredentialException]: User cancellation, configuration errors, or no credentials + * - Configuration errors trigger detailed developer guidance logs + * + * @param context Android context for Credential Manager + * @param config Authentication UI configuration + * @param provider Google provider configuration with optional scopes + * @param authorizationProvider Provider for OAuth scopes authorization (for testing) + * @param credentialManagerProvider Provider for Credential Manager flow (for testing) + * + * @throws AuthException.InvalidCredentialsException if token parsing fails + * @throws AuthException.AuthCancelledException if user cancels or no accounts found + * @throws AuthException if sign-in or linking fails + * + * @see AuthProvider.Google + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithGoogle( + context: Context, + config: AuthUIConfiguration, + provider: AuthProvider.Google, + authorizationProvider: AuthProvider.Google.AuthorizationProvider = AuthProvider.Google.DefaultAuthorizationProvider(), + credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), +) { + var idTokenFromResult: String? = null + try { + updateAuthState(AuthState.Loading("Signing in with google...")) + + // Request OAuth scopes if specified (before sign-in) + if (provider.scopes.isNotEmpty()) { + try { + val requestedScopes = provider.scopes.map { Scope(it) } + authorizationProvider.authorize(context, requestedScopes) + } catch (e: Exception) { + // Continue with sign-in even if scope authorization fails + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + + // Try with configured filterByAuthorizedAccounts setting + // If default (true), fallback to false if no authorized accounts found + // See: https://developer.android.com/identity/sign-in/credential-manager-siwg#siwg-button + val result = if (provider.filterByAuthorizedAccounts) { + // Default behavior: Try authorized accounts first, fallback to all accounts + try { + (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + context = context, + credentialManager = CredentialManager.create(context), + serverClientId = provider.serverClientId!!, + filterByAuthorizedAccounts = true, + autoSelectEnabled = provider.autoSelectEnabled + ) + } catch (e: NoCredentialException) { + // No authorized accounts found, try again with all accounts for sign-up flow + Log.d("GoogleAuthProvider", "No authorized accounts found, showing all Google accounts for sign-up") + try { + (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + context = context, + credentialManager = CredentialManager.create(context), + serverClientId = provider.serverClientId!!, + filterByAuthorizedAccounts = false, + autoSelectEnabled = provider.autoSelectEnabled + ) + } catch (fallbackException: NoCredentialException) { + // No Google accounts available on device at all + throw AuthException.UnknownException( + message = "No Google accounts available.\n\nPlease add a Google account to your device and try again.", + cause = fallbackException + ) + } + } + } else { + // Developer explicitly wants to show all accounts (no fallback needed) + (testCredentialManagerProvider ?: credentialManagerProvider).getGoogleCredential( + context = context, + credentialManager = CredentialManager.create(context), + serverClientId = provider.serverClientId!!, + filterByAuthorizedAccounts = false, + autoSelectEnabled = provider.autoSelectEnabled + ) + } + idTokenFromResult = result.idToken + + signInAndLinkWithCredential( + config = config, + credential = result.credential, + provider = provider, + displayName = result.displayName, + photoUrl = result.photoUrl, + ) + + // Save sign-in preference for "Continue as..." feature + try { + val user = auth.currentUser + val identifier = user?.email + if (identifier != null) { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = provider.providerId, + identifier = identifier + ) + Log.d("GoogleAuthProvider", "Sign-in preference saved for: $identifier") + } + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + android.util.Log.w("GoogleAuthProvider", "Failed to save sign-in preference", e) + } + } catch (e: AuthException.AccountLinkingRequiredException) { + // Account collision occurred - save Facebook credential for linking after email link sign-in + // This happens when a user tries to sign in with Facebook but an email link account exists + EmailLinkPersistenceManager.default.saveCredentialForLinking( + context = context, + providerType = provider.providerId, + idToken = idTokenFromResult, + accessToken = null + ) + + // Re-throw to let UI handle the account linking flow + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with google was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs out from Google and clears credential state. + * + * This function clears the cached Google credentials, ensuring that the account picker + * will be shown on the next sign-in attempt instead of automatically signing in with + * the previously used account. + * + * **When to call:** + * - After user explicitly signs out + * - Before allowing user to select a different Google account + * - When switching between accounts + * + * **Note:** This does not sign out from Firebase Auth itself. Call [FirebaseAuthUI.signOut] + * separately if you need to sign out from Firebase. + * + * @param context Android context for Credential Manager + */ +internal suspend fun FirebaseAuthUI.signOutFromGoogle( + context: Context, + credentialManagerProvider: AuthProvider.Google.CredentialManagerProvider = AuthProvider.Google.DefaultCredentialManagerProvider(), +) { + try { + if (Provider.fromId(getCurrentUser()?.providerId) != Provider.GOOGLE) return + (testCredentialManagerProvider ?: credentialManagerProvider).clearCredentialState( + context = context, + credentialManager = CredentialManager.create(context) + ) + } catch (e: Exception) { + Log.e("GoogleAuthProvider", "Error during Google sign out", e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..4850657469 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,238 @@ +package com.firebase.ui.auth.configuration.auth_provider + +import android.app.Activity +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.firebase.auth.FirebaseAuthUserCollisionException +import com.google.firebase.auth.OAuthCredential +import com.google.firebase.auth.OAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Creates a Composable handler for OAuth provider sign-in. + * + * This function creates a remember-scoped sign-in handler that can be invoked + * from button clicks or other UI events. It automatically handles: + * - Activity retrieval from LocalActivity + * - Coroutine scope management + * - Error handling and state updates + * + * **Usage:** + * ```kotlin + * val onSignInWithGitHub = authUI.rememberOAuthSignInHandler( + * config = configuration, + * provider = githubProvider + * ) + * + * Button(onClick = onSignInWithGitHub) { + * Text("Sign in with GitHub") + * } + * ``` + * + * @param config Authentication UI configuration + * @param provider OAuth provider configuration + * + * @return Lambda that triggers OAuth sign-in when invoked + * + * @throws IllegalStateException if LocalActivity.current is null + * + * @see signInWithProvider + */ +@Composable +internal fun FirebaseAuthUI.rememberOAuthSignInHandler( + context: Context, + activity: Activity?, + config: AuthUIConfiguration, + provider: AuthProvider.OAuth, +): () -> Unit { + val coroutineScope = rememberCoroutineScope() + activity ?: throw IllegalStateException( + "OAuth sign-in requires an Activity. " + + "Ensure FirebaseAuthScreen is used within an Activity." + ) + + return remember(this, provider.providerId) { + { + coroutineScope.launch { + try { + signInWithProvider( + context = context, + config = config, + activity = activity, + provider = provider + ) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + } + } + } + } +} + +/** + * Signs in with an OAuth provider (GitHub, Microsoft, Yahoo, Apple, Twitter). + * + * This function implements OAuth provider authentication using Firebase's native OAuthProvider. + * It handles both normal sign-in flow and anonymous user upgrade flow. + * + * **Supported Providers:** + * - GitHub (github.com) + * - Microsoft (microsoft.com) + * - Yahoo (yahoo.com) + * - Apple (apple.com) + * - Twitter (twitter.com) + * + * **Flow:** + * 1. Checks for pending auth results (e.g., from app restart during OAuth flow) + * 2. If anonymous upgrade is enabled and user is anonymous, links credential to anonymous account + * 3. Otherwise, performs normal sign-in + * 4. Updates auth state to Idle on success + * + * **Anonymous Upgrade:** + * If [AuthUIConfiguration.isAnonymousUpgradeEnabled] is true and a user is currently signed in + * anonymously, this will attempt to link the OAuth credential to the anonymous account instead + * of creating a new account. + * + * **Error Handling:** + * - [AuthException.AuthCancelledException]: User cancelled OAuth flow + * - [AuthException.AccountLinkingRequiredException]: Account collision (email already exists) + * - [AuthException]: Other authentication errors + * + * @param config Authentication UI configuration + * @param activity Activity for OAuth flow + * @param provider OAuth provider configuration with scopes and custom parameters + * + * @throws AuthException.AuthCancelledException if user cancels + * @throws AuthException.AccountLinkingRequiredException if account collision occurs + * @throws AuthException if OAuth flow or sign-in fails + * + * @see AuthProvider.OAuth + * @see signInAndLinkWithCredential + */ +internal suspend fun FirebaseAuthUI.signInWithProvider( + context: Context, + config: AuthUIConfiguration, + activity: Activity, + provider: AuthProvider.OAuth, +) { + try { + updateAuthState(AuthState.Loading("Signing in with ${provider.providerName}...")) + + // Build OAuth provider with scopes and custom parameters + val oauthProvider = OAuthProvider + .newBuilder(provider.providerId) + .apply { + // Add scopes if provided + if (provider.scopes.isNotEmpty()) { + scopes = provider.scopes + } + // Add custom parameters if provided + provider.customParameters.forEach { (key, value) -> + addCustomParameter(key, value) + } + } + .build() + + // Check for pending auth result (e.g., app was killed during OAuth flow) + val pendingResult = auth.pendingAuthResult + if (pendingResult != null) { + val authResult = pendingResult.await() + val credential = authResult.credential as? OAuthCredential + + if (credential != null) { + // Complete the pending sign-in/link flow + signInAndLinkWithCredential( + config = config, + credential = credential, + provider = provider, + displayName = authResult.user?.displayName, + photoUrl = authResult.user?.photoUrl, + ) + } + updateAuthState(AuthState.Idle) + return + } + + // Determine if we should upgrade anonymous user or do normal sign-in + val authResult = if (canUpgradeAnonymous(config, auth)) { + auth.currentUser?.startActivityForLinkWithProvider(activity, oauthProvider)?.await() + } else { + auth.startActivityForSignInWithProvider(activity, oauthProvider).await() + } + + // Extract OAuth credential and complete sign-in + val credential = authResult?.credential as? OAuthCredential + if (credential != null) { + // The user is already signed in via startActivityForSignInWithProvider/startActivityForLinkWithProvider + + // Save sign-in preference for "Continue as..." feature + try { + val user = auth.currentUser + val identifier = user?.email + if (identifier != null) { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = provider.providerId, + identifier = identifier + ) + android.util.Log.d("OAuthProvider", "Sign-in preference saved for: $identifier (${provider.providerId})") + } + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + android.util.Log.w("OAuthProvider", "Failed to save sign-in preference", e) + } + + // Just update state to Idle + updateAuthState(AuthState.Idle) + } else { + throw AuthException.UnknownException( + message = "OAuth sign-in did not return a valid credential" + ) + } + + } catch (e: FirebaseAuthUserCollisionException) { + // Account collision: account already exists with different sign-in method + val email = e.email + val credential = e.updatedCredential + + val accountLinkingException = AuthException.AccountLinkingRequiredException( + message = "An account already exists with the email ${email ?: ""}. " + + "Please sign in with your existing account to link " + + "your ${provider.providerName} account.", + email = email, + credential = credential, + cause = e + ) + updateAuthState(AuthState.Error(accountLinkingException)) + throw accountLinkingException + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Signing in with ${provider.providerName} was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt new file mode 100644 index 0000000000..0be8ee8fa6 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -0,0 +1,341 @@ +package com.firebase.ui.auth.configuration.auth_provider + +import android.app.Activity +import android.content.Context +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.MultiFactorSession +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.CancellationException + +/** + * Initiates phone number verification with Firebase Phone Authentication. + * + * This method starts the phone verification flow, which can complete in two ways: + * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies + * the SMS code without user interaction. This happens when Google Play services can + * detect the incoming SMS automatically. + * 2. **Manual verification**: SMS code is sent to the user's device, and the user must + * manually enter the code via [submitVerificationCode]. + * + * **Flow:** + * - Call this method with the phone number + * - Firebase SDK attempts instant verification + * - If instant verification succeeds: + * - Emits [AuthState.SMSAutoVerified] with the credential + * - UI should observe this state and call [signInWithPhoneAuthCredential] + * - If instant verification fails: + * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details + * - UI should show code entry screen + * - User enters code → call [submitVerificationCode] + * + * **Resending codes:** + * To resend a verification code, call this method again with: + * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired] + * + * **Example: Basic phone verification** + * ```kotlin + * // Step 1: Start verification + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * ) + * + * // Step 2: Observe AuthState + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Instant verification succeeded! + * showToast("Phone number verified automatically") + * // Now sign in with the credential + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * } + * is AuthState.PhoneNumberVerificationRequired -> { + * // Show code entry screen + * showCodeEntryScreen( + * verificationId = state.verificationId, + * forceResendingToken = state.forceResendingToken + * ) + * } + * is AuthState.Error -> { + * // Handle error + * showError(state.exception.message) + * } + * } + * } + * + * // Step 3: When user enters code + * firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = verificationId, + * code = userEnteredCode + * ) + * ``` + * + * **Example: Resending verification code** + * ```kotlin + * // User didn't receive the code, wants to resend + * firebaseAuthUI.verifyPhoneNumber( + * provider = phoneProvider, + * phoneNumber = "+1234567890", + * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state + * ) + * ``` + * + * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings + * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890") + * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, + * this initiates phone verification for enrolling a second factor rather than primary sign-in. + * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA. + * @param forceResendingToken Optional token from previous verification for resending SMS + * + * @throws AuthException.InvalidCredentialsException if the phone number is invalid + * @throws AuthException.TooManyRequestsException if SMS quota is exceeded + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.verifyPhoneNumber( + provider: AuthProvider.Phone, + activity: Activity?, + phoneNumber: String, + multiFactorSession: MultiFactorSession? = null, + forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, + verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), +) { + try { + updateAuthState(AuthState.Loading("Verifying phone number...")) + val result = provider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = forceResendingToken, + verifier = verifier + ) + when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) + } + + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + updateAuthState( + AuthState.PhoneNumberVerificationRequired( + verificationId = result.verificationId, + forceResendingToken = result.token, + ) + ) + } + } + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Verify phone number was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Submits a verification code entered by the user and signs them in. + * + * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired], + * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the + * verification ID and user-entered code, then signs in the user by calling + * [signInWithPhoneAuthCredential]. + * + * **Flow:** + * 1. User receives SMS with 6-digit code + * 2. User enters code in UI + * 3. UI calls this method with the code + * 4. Credential is created and used to sign in + * 5. Returns [AuthResult] with signed-in user + * + * This method handles both normal sign-in and anonymous account upgrade scenarios based + * on the [AuthUIConfiguration] settings. + * + * **Example: Manual code entry flow* + * ``` + * val userEnteredCode = "123456" + * try { + * val result = firebaseAuthUI.submitVerificationCode( + * config = authUIConfig, + * verificationId = savedVerificationId!!, + * code = userEnteredCode + * ) + * // User is now signed in + * } catch (e: AuthException.InvalidCredentialsException) { + * // Wrong code entered + * showError("Invalid verification code") + * } catch (e: AuthException.SessionExpiredException) { + * // Code expired + * showError("Verification code expired. Please request a new one.") + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired] + * @param code The 6-digit verification code entered by the user + * + * @return [AuthResult] containing the signed-in user + * + * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.submitVerificationCode( + context: Context, + config: AuthUIConfiguration, + verificationId: String, + code: String, + credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Submitting verification code...")) + val credential = credentialProvider.getCredential(verificationId, code) + return signInWithPhoneAuthCredential( + context = context, + config = config, + credential = credential + ) + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Submit verification code was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} + +/** + * Signs in a user with a phone authentication credential. + * + * This method is the final step in the phone authentication flow. It takes a + * [PhoneAuthCredential] (either from instant verification or manual code entry) and + * signs in the user. The method handles both normal sign-in and anonymous account + * upgrade scenarios by delegating to [signInAndLinkWithCredential]. + * + * **When to call this:** + * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification) + * - Called internally by [submitVerificationCode] (manual verification) + * + * The method automatically handles: + * - Normal sign-in for new or returning users + * - Linking phone credential to anonymous accounts (if enabled in config) + * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account + * + * **Example: Sign in after instant verification** + * ```kotlin + * authUI.authStateFlow().collect { state -> + * when (state) { + * is AuthState.SMSAutoVerified -> { + * // Phone was instantly verified + * showToast("Phone verified automatically!") + * + * // Now sign in with the credential + * val result = firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = state.credential + * ) + * // User is now signed in + * } + * } + * } + * ``` + * + * **Example: Anonymous upgrade with collision** + * ```kotlin + * // User is currently anonymous + * try { + * firebaseAuthUI.signInWithPhoneAuthCredential( + * config = authUIConfig, + * credential = phoneCredential + * ) + * } catch (e: AuthException.AccountLinkingRequiredException) { + * // Phone number already exists on another account + * // Account linking required - show account linking screen + * // User needs to sign in with existing account to link + * } + * ``` + * + * @param config The [AuthUIConfiguration] containing authentication settings + * @param credential The [PhoneAuthCredential] to use for signing in + * + * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred + * + * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired + * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account + * @throws AuthException.AuthCancelledException if the operation is cancelled + * @throws AuthException.NetworkException if a network error occurs + */ +internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( + context: Context, + config: AuthUIConfiguration, + credential: PhoneAuthCredential, +): AuthResult? { + try { + updateAuthState(AuthState.Loading("Signing in with phone...")) + val result = signInAndLinkWithCredential( + config = config, + credential = credential, + ) + + // Save sign-in preference for "Continue as..." feature + if (result != null) { + try { + val user = auth.currentUser + val identifier = user?.phoneNumber + if (identifier != null) { + SignInPreferenceManager.saveLastSignIn( + context = context, + providerId = "phone", + identifier = identifier + ) + android.util.Log.d("PhoneAuthProvider", "Sign-in preference saved for: $identifier") + } + } catch (e: Exception) { + // Failed to save preference - log but don't break auth flow + android.util.Log.w("PhoneAuthProvider", "Failed to save sign-in preference", e) + } + } + + return result + } catch (e: CancellationException) { + val cancelledException = AuthException.AuthCancelledException( + message = "Sign in with phone was cancelled", + cause = e + ) + updateAuthState(AuthState.Error(cancelledException)) + throw cancelledException + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + throw e + } catch (e: Exception) { + val authException = AuthException.from(e) + updateAuthState(AuthState.Error(authException)) + throw authException + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt new file mode 100644 index 0000000000..a062debddc --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -0,0 +1,545 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.string_provider + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * CompositionLocal for providing [AuthUIStringProvider] throughout the Compose tree. + * + * This allows accessing localized strings without manually passing the provider through + * every composable. The provider is set at the top level in FirebaseAuthScreen and can + * be accessed anywhere in the auth UI using `LocalAuthUIStringProvider.current`. + * + * **Usage:** + * ```kotlin + * @Composable + * fun MyAuthComponent() { + * val stringProvider = LocalAuthUIStringProvider.current + * Text(stringProvider.signInWithGoogle) + * } + * ``` + * + * @since 10.0.0 + */ +val LocalAuthUIStringProvider = staticCompositionLocalOf { + error("No AuthUIStringProvider provided. Ensure FirebaseAuthScreen is used as the root composable.") +} + +/** + * An interface for providing localized string resources. This interface defines methods for all + * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmailAddress(), + * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. + * + * @sample AuthUIStringProviderSample + */ +interface AuthUIStringProvider { + /** Loading text displayed during initialization or processing states */ + val initializing: String + + /** Text for Google Provider */ + val googleProvider: String + + /** Text for Facebook Provider */ + val facebookProvider: String + + /** Text for Twitter Provider */ + val twitterProvider: String + + /** Text for Github Provider */ + val githubProvider: String + + /** Text for Phone Provider */ + val phoneProvider: String + + /** Text for Email Provider */ + val emailProvider: String + + /** Button text for Google sign-in option */ + val signInWithGoogle: String + + /** Button text for Facebook sign-in option */ + val signInWithFacebook: String + + /** Button text for Twitter sign-in option */ + val signInWithTwitter: String + + /** Button text for Github sign-in option */ + val signInWithGithub: String + + /** Button text for Email sign-in option */ + val signInWithEmail: String + + /** Button text for Phone sign-in option */ + val signInWithPhone: String + + /** Button text for Anonymous sign-in option */ + val signInAnonymously: String + + /** Button text for Apple sign-in option */ + val signInWithApple: String + + /** Button text for Microsoft sign-in option */ + val signInWithMicrosoft: String + + /** Button text for Yahoo sign-in option */ + val signInWithYahoo: String + + /** Button text for Google continue option */ + val continueWithGoogle: String + + /** Button text for Facebook continue option */ + val continueWithFacebook: String + + /** Button text for Twitter continue option */ + val continueWithTwitter: String + + /** Button text for Github continue option */ + val continueWithGithub: String + + /** Button text for Email continue option */ + val continueWithEmail: String + + /** Button text for Phone continue option */ + val continueWithPhone: String + + /** Button text for Apple continue option */ + val continueWithApple: String + + /** Button text for Microsoft continue option */ + val continueWithMicrosoft: String + + /** Button text for Yahoo continue option */ + val continueWithYahoo: String + + /** Error message when email address field is empty */ + val missingEmailAddress: String + + /** Error message when email address format is invalid */ + val invalidEmailAddress: String + + /** Generic error message for incorrect password during sign-in */ + val invalidPassword: String + + /** Error message when password confirmation doesn't match the original password */ + val passwordsDoNotMatch: String + + /** Error message when password doesn't meet minimum length requirement. Should support string formatting with minimum length parameter. */ + fun passwordTooShort(minimumLength: Int): String + + /** Error message when password is missing at least one uppercase letter (A-Z) */ + val passwordMissingUppercase: String + + /** Error message when password is missing at least one lowercase letter (a-z) */ + val passwordMissingLowercase: String + + /** Error message when password is missing at least one numeric digit (0-9) */ + val passwordMissingDigit: String + + /** Error message when password is missing at least one special character */ + val passwordMissingSpecialCharacter: String + + // Email Authentication Strings + /** Title for email signup form */ + val signupPageTitle: String + + /** Hint for email input field */ + val emailHint: String + + /** Hint for password input field */ + val passwordHint: String + + /** Hint for confirm password input field */ + val confirmPasswordHint: String + + /** Hint for new password input field */ + val newPasswordHint: String + + /** Hint for name input field */ + val nameHint: String + + /** Button text to save form */ + val buttonTextSave: String + + /** Welcome back header for email users */ + val welcomeBackEmailHeader: String + + /** Trouble signing in link text */ + val troubleSigningIn: String + + /** Title for recover password page */ + val recoverPasswordPageTitle: String + + /** Button text for reset password */ + val sendButtonText: String + + /** Title for recover password link sent dialog */ + val recoverPasswordLinkSentDialogTitle: String + + /** Body for recover password link sent dialog */ + fun recoverPasswordLinkSentDialogBody(email: String): String + + /** Title for email sign in link sent dialog */ + val emailSignInLinkSentDialogTitle: String + + /** Body for email sign in link sent dialog */ + fun emailSignInLinkSentDialogBody(email: String): String + + /** Divider text for alternate sign-in options */ + val orContinueWith: String + + /** Button text to sign in with email link */ + val signInWithEmailLink: String + + /** Button text to sign in with password */ + val signInWithPassword: String + + /** Title shown when prompting the user to confirm their email for cross-device flows */ + val emailLinkPromptForEmailTitle: String + + /** Message shown when prompting the user to confirm their email for cross-device flows */ + val emailLinkPromptForEmailMessage: String + + /** Title shown when email link must be opened on same device */ + val emailLinkWrongDeviceTitle: String + + /** Message shown when email link must be opened on same device */ + val emailLinkWrongDeviceMessage: String + + /** Title shown when the anonymous session differs */ + val emailLinkDifferentAnonymousUserTitle: String + + /** Message shown when the anonymous session differs */ + val emailLinkDifferentAnonymousUserMessage: String + + /** Message shown for cross-device linking flows with the provider name */ + fun emailLinkCrossDeviceLinkingMessage(providerName: String): String + + /** Title shown when email link is invalid */ + val emailLinkInvalidLinkTitle: String + + /** Message shown when email link is invalid */ + val emailLinkInvalidLinkMessage: String + + /** Message shown when email mismatch occurs */ + val emailMismatchMessage: String + + // Phone Authentication Strings + /** Phone number entry form title */ + val verifyPhoneNumberTitle: String + + /** Hint for phone input field */ + val phoneHint: String + + /** Hint for country input field */ + val countryHint: String + + /** Invalid phone number error */ + val invalidPhoneNumber: String + + /** Missing phone number error */ + val missingPhoneNumber: String + + /** Phone verification code entry form title */ + val enterConfirmationCode: String + + /** Button text to verify phone number */ + val verifyPhoneNumber: String + + /** Resend code countdown timer */ + val resendCodeIn: String + + /** Resend code link text */ + val resendCode: String + + /** Resend code with timer */ + fun resendCodeTimer(timeFormatted: String): String + + /** Verifying progress text */ + val verifying: String + + /** Wrong verification code error */ + val incorrectCodeDialogBody: String + + /** SMS terms of service warning */ + val smsTermsOfService: String + + /** Enter phone number title */ + val enterPhoneNumberTitle: String + + /** Phone number hint */ + val phoneNumberHint: String + + /** Send verification code button text */ + val sendVerificationCode: String + + /** Enter verification code title with phone number */ + fun enterVerificationCodeTitle(phoneNumber: String): String + + /** Verification code hint */ + val verificationCodeHint: String + + /** Change phone number link text */ + val changePhoneNumber: String + + /** Missing verification code error */ + val missingVerificationCode: String + + /** Invalid verification code error */ + val invalidVerificationCode: String + + /** Select country modal sheet title */ + val countrySelectorModalTitle: String + + /** Select country modal sheet input field hint */ + val searchCountriesHint: String + + // Provider Picker Strings + /** Common button text for sign in */ + val signInDefault: String + + /** Common button text for continue */ + val continueText: String + + /** Common button text for next */ + val nextDefault: String + + // General Error Messages + /** General unknown error message */ + val errorUnknown: String + + /** Required field error */ + val requiredField: String + + /** Loading progress text */ + val progressDialogLoading: String + + /** Label shown when the user is signed in. String should contain a single %s placeholder. */ + fun signedInAs(userIdentifier: String): String + + /** Action text for managing multi-factor authentication settings. */ + val manageMfaAction: String + + /** Action text for signing out. */ + val signOutAction: String + + /** Instruction shown when the user must verify their email. Accepts the email value. */ + fun verifyEmailInstruction(email: String): String + + /** Action text for resending the verification email. */ + val resendVerificationEmailAction: String + + /** Action text once the user has verified their email. */ + val verifiedEmailAction: String + + /** Message shown when profile completion is required. */ + val profileCompletionMessage: String + + /** Message listing missing profile fields. Accepts a comma-separated list. */ + fun profileMissingFieldsMessage(fields: String): String + + /** Action text for skipping an optional step. */ + val skipAction: String + + /** Action text for removing an item (for example, an MFA factor). */ + val removeAction: String + + /** Action text for navigating back. */ + val backAction: String + + /** Action text for confirming verification. */ + val verifyAction: String + + /** Action text for choosing a different factor during MFA challenge. */ + val useDifferentMethodAction: String + + /** Action text for confirming recovery codes have been saved. */ + val recoveryCodesSavedAction: String + + /** Label for secret key text displayed during TOTP setup. */ + val secretKeyLabel: String + + /** Label for verification code input fields. */ + val verificationCodeLabel: String + + /** Generic identity verified confirmation message. */ + val identityVerifiedMessage: String + + /** Title for the manage MFA screen. */ + val mfaManageFactorsTitle: String + + /** Helper description for the manage MFA screen. */ + val mfaManageFactorsDescription: String + + /** Header for the list of currently enrolled MFA factors. */ + val mfaActiveMethodsTitle: String + + /** Header for the list of available MFA factors to enroll. */ + val mfaAddNewMethodTitle: String + + /** Message shown when all factors are already enrolled. */ + val mfaAllMethodsEnrolledMessage: String + + /** Label for SMS MFA factor. */ + val smsAuthenticationLabel: String + + /** Label for authenticator-app MFA factor. */ + val totpAuthenticationLabel: String + + /** Label used when the factor type is unknown. */ + val unknownMethodLabel: String + + /** Label describing the enrollment date. Accepts a formatted date string. */ + fun enrolledOnDateLabel(date: String): String + + /** Description displayed during authenticator app setup. */ + val setupAuthenticatorDescription: String + + /** Network error message */ + val noInternet: String + + /** TOTP Code prompt */ + val enterTOTPCode: String + + // Error Recovery Dialog Strings + /** Error dialog title */ + val errorDialogTitle: String + + /** Retry action button text */ + val retryAction: String + + /** Dismiss action button text */ + val dismissAction: String + + /** Network error recovery message */ + val networkErrorRecoveryMessage: String + + /** Invalid credentials recovery message */ + val invalidCredentialsRecoveryMessage: String + + /** User not found recovery message */ + val userNotFoundRecoveryMessage: String + + /** Weak password recovery message */ + val weakPasswordRecoveryMessage: String + + /** Email already in use recovery message */ + val emailAlreadyInUseRecoveryMessage: String + + /** Too many requests recovery message */ + val tooManyRequestsRecoveryMessage: String + + /** MFA required recovery message */ + val mfaRequiredRecoveryMessage: String + + /** Account linking required recovery message */ + val accountLinkingRequiredRecoveryMessage: String + + /** Auth cancelled recovery message */ + val authCancelledRecoveryMessage: String + + /** Unknown error recovery message */ + val unknownErrorRecoveryMessage: String + + // MFA Enrollment Step Titles + /** Title for MFA factor selection step */ + val mfaStepSelectFactorTitle: String + + /** Title for SMS MFA configuration step */ + val mfaStepConfigureSmsTitle: String + + /** Title for TOTP MFA configuration step */ + val mfaStepConfigureTotpTitle: String + + /** Title for MFA verification step */ + val mfaStepVerifyFactorTitle: String + + /** Title for recovery codes step */ + val mfaStepShowRecoveryCodesTitle: String + + // MFA Enrollment Helper Text + /** Helper text for selecting MFA factor */ + val mfaStepSelectFactorHelper: String + + /** Helper text for SMS configuration */ + val mfaStepConfigureSmsHelper: String + + /** Helper text for TOTP configuration */ + val mfaStepConfigureTotpHelper: String + + /** Helper text for SMS verification */ + val mfaStepVerifyFactorSmsHelper: String + + /** Helper text for TOTP verification */ + val mfaStepVerifyFactorTotpHelper: String + + /** Generic helper text for factor verification */ + val mfaStepVerifyFactorGenericHelper: String + + /** Helper text for recovery codes */ + val mfaStepShowRecoveryCodesHelper: String + + // MFA Enrollment Screen Titles + /** Title for MFA phone number enrollment screen (top app bar) */ + val mfaEnrollmentEnterPhoneNumber: String + + /** Title for MFA SMS verification screen (top app bar) */ + val mfaEnrollmentVerifySmsCode: String + + // MFA Error Messages + /** Error message when MFA enrollment requires recent authentication */ + val mfaErrorRecentLoginRequired: String + + /** Error message when MFA enrollment fails due to invalid verification code */ + val mfaErrorInvalidVerificationCode: String + + /** Error message when MFA enrollment fails due to network issues */ + val mfaErrorNetwork: String + + /** Generic error message for MFA enrollment failures */ + val mfaErrorGeneric: String + + // Re-authentication Dialog + /** Title displayed in the re-authentication dialog. */ + val reauthDialogTitle: String + + /** Descriptive message shown in the re-authentication dialog. */ + val reauthDialogMessage: String + + /** Label showing the account email being re-authenticated. */ + fun reauthAccountLabel(email: String): String + + /** Error message shown when the provided password is incorrect. */ + val incorrectPasswordError: String + + /** General error message for re-authentication failures. */ + val reauthGenericError: String + + // Terms of Service and Privacy Policy + /** Terms of Service link text */ + val termsOfService: String + + /** Privacy Policy link text */ + val privacyPolicy: String + + /** ToS and Privacy Policy combined message with placeholders for links */ + fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String + + /** Tooltip message shown when new account sign-up is disabled */ + val newAccountsDisabledTooltip: String + + /** Tooltip message shown when MFA is disabled */ + val mfaDisabledTooltip: String +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt new file mode 100644 index 0000000000..cb82bee993 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.string_provider + +import android.content.Context +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider + +class AuthUIStringProviderSample { + /** + * Override specific strings while delegating others to default provider + */ + class CustomAuthUIStringProvider( + private val defaultProvider: AuthUIStringProvider + ) : AuthUIStringProvider by defaultProvider { + + // Override only the strings you want to customize + override val signInWithGoogle: String = "Continue with Google • MyApp" + override val signInWithFacebook: String = "Continue with Facebook • MyApp" + + // Add custom branding to common actions + override val continueText: String = "Continue to MyApp" + override val signInDefault: String = "Sign in to MyApp" + + // Custom MFA messaging + override val enterTOTPCode: String = + "Enter the 6-digit code from your authenticator app to secure your MyApp account" + } + + fun createCustomConfiguration(applicationContext: Context): AuthUIConfiguration { + val customStringProvider = + CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext)) + return authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Google( + scopes = listOf(), + serverClientId = "" + ) + ) + } + stringProvider = customStringProvider + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt new file mode 100644 index 0000000000..429d6d286d --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -0,0 +1,497 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.string_provider + +import android.content.Context +import android.content.res.Configuration +import com.firebase.ui.auth.R +import java.util.Locale + +class DefaultAuthUIStringProvider( + context: Context, + locale: Locale? = null, +) : AuthUIStringProvider { + /** + * Allows overriding locale. + */ + private val localizedContext = locale?.let { locale -> + context.createConfigurationContext( + Configuration(context.resources.configuration).apply { + setLocale(locale) + } + ) + } ?: context + + /** + * Common Strings + */ + override val initializing: String + get() = "Initializing" + + /** + * Auth Provider strings + */ + override val googleProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_google) + override val facebookProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_facebook) + override val twitterProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_twitter) + override val githubProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_github) + override val phoneProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_phone) + override val emailProvider: String + get() = localizedContext.getString(R.string.fui_idp_name_email) + + /** + * Auth Provider Button Strings + */ + override val signInWithGoogle: String + get() = localizedContext.getString(R.string.fui_sign_in_with_google) + override val signInWithFacebook: String + get() = localizedContext.getString(R.string.fui_sign_in_with_facebook) + override val signInWithTwitter: String + get() = localizedContext.getString(R.string.fui_sign_in_with_twitter) + override val signInWithGithub: String + get() = localizedContext.getString(R.string.fui_sign_in_with_github) + override val signInWithEmail: String + get() = localizedContext.getString(R.string.fui_sign_in_with_email) + override val signInWithPhone: String + get() = localizedContext.getString(R.string.fui_sign_in_with_phone) + override val signInAnonymously: String + get() = localizedContext.getString(R.string.fui_sign_in_anonymously) + override val signInWithApple: String + get() = localizedContext.getString(R.string.fui_sign_in_with_apple) + override val signInWithMicrosoft: String + get() = localizedContext.getString(R.string.fui_sign_in_with_microsoft) + override val signInWithYahoo: String + get() = localizedContext.getString(R.string.fui_sign_in_with_yahoo) + + /** + * Auth Provider "Continue With" Button Strings + */ + override val continueWithGoogle: String + get() = localizedContext.getString(R.string.fui_continue_with_google) + override val continueWithFacebook: String + get() = localizedContext.getString(R.string.fui_continue_with_facebook) + override val continueWithTwitter: String + get() = localizedContext.getString(R.string.fui_continue_with_twitter) + override val continueWithGithub: String + get() = localizedContext.getString(R.string.fui_continue_with_github) + override val continueWithEmail: String + get() = localizedContext.getString(R.string.fui_continue_with_email) + override val continueWithPhone: String + get() = localizedContext.getString(R.string.fui_continue_with_phone) + override val continueWithApple: String + get() = localizedContext.getString(R.string.fui_continue_with_apple) + override val continueWithMicrosoft: String + get() = localizedContext.getString(R.string.fui_continue_with_microsoft) + override val continueWithYahoo: String + get() = localizedContext.getString(R.string.fui_continue_with_yahoo) + + /** + * Email Validator Strings + */ + override val missingEmailAddress: String + get() = localizedContext.getString(R.string.fui_missing_email_address) + override val invalidEmailAddress: String + get() = localizedContext.getString(R.string.fui_invalid_email_address) + + /** + * Password Validator Strings + */ + override val invalidPassword: String + get() = localizedContext.getString(R.string.fui_error_invalid_password) + override val passwordsDoNotMatch: String + get() = localizedContext.getString(R.string.fui_passwords_do_not_match) + + override fun passwordTooShort(minimumLength: Int): String = + localizedContext.getString(R.string.fui_error_password_too_short, minimumLength) + + override val passwordMissingUppercase: String + get() = localizedContext.getString(R.string.fui_error_password_missing_uppercase) + override val passwordMissingLowercase: String + get() = localizedContext.getString(R.string.fui_error_password_missing_lowercase) + override val passwordMissingDigit: String + get() = localizedContext.getString(R.string.fui_error_password_missing_digit) + override val passwordMissingSpecialCharacter: String + get() = localizedContext.getString(R.string.fui_error_password_missing_special_character) + + /** + * Email Authentication Strings + */ + override val signupPageTitle: String + get() = localizedContext.getString(R.string.fui_title_register_email) + override val emailHint: String + get() = localizedContext.getString(R.string.fui_email_hint) + override val passwordHint: String + get() = localizedContext.getString(R.string.fui_password_hint) + override val confirmPasswordHint: String + get() = localizedContext.getString(R.string.fui_confirm_password_hint) + override val newPasswordHint: String + get() = localizedContext.getString(R.string.fui_new_password_hint) + override val nameHint: String + get() = localizedContext.getString(R.string.fui_name_hint) + override val buttonTextSave: String + get() = localizedContext.getString(R.string.fui_button_text_save) + override val welcomeBackEmailHeader: String + get() = localizedContext.getString(R.string.fui_welcome_back_email_header) + override val troubleSigningIn: String + get() = localizedContext.getString(R.string.fui_trouble_signing_in) + + override val recoverPasswordPageTitle: String + get() = localizedContext.getString(R.string.fui_title_recover_password_activity) + + override val sendButtonText: String + get() = localizedContext.getString(R.string.fui_button_text_send) + + override val recoverPasswordLinkSentDialogTitle: String + get() = localizedContext.getString(R.string.fui_title_confirm_recover_password) + + override fun recoverPasswordLinkSentDialogBody(email: String): String = + localizedContext.getString(R.string.fui_confirm_recovery_body, email) + + override val emailSignInLinkSentDialogTitle: String + get() = localizedContext.getString(R.string.fui_email_link_header) + + override fun emailSignInLinkSentDialogBody(email: String): String = + localizedContext.getString(R.string.fui_email_link_email_sent, email) + + override val orContinueWith: String + get() = localizedContext.getString(R.string.fui_or_continue_with) + + override val signInWithEmailLink: String + get() = localizedContext.getString(R.string.fui_sign_in_with_email_link) + + override val signInWithPassword: String + get() = localizedContext.getString(R.string.fui_sign_in_with_password) + + override val emailLinkPromptForEmailTitle: String + get() = localizedContext.getString(R.string.fui_email_link_confirm_email_header) + + override val emailLinkPromptForEmailMessage: String + get() = localizedContext.getString(R.string.fui_email_link_confirm_email_message) + + override val emailLinkWrongDeviceTitle: String + get() = localizedContext.getString(R.string.fui_email_link_wrong_device_header) + + override val emailLinkWrongDeviceMessage: String + get() = localizedContext.getString(R.string.fui_email_link_wrong_device_message) + + override val emailLinkDifferentAnonymousUserTitle: String + get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_header) + + override val emailLinkDifferentAnonymousUserMessage: String + get() = localizedContext.getString(R.string.fui_email_link_different_anonymous_user_message) + + override fun emailLinkCrossDeviceLinkingMessage(providerName: String): String = + localizedContext.getString( + R.string.fui_email_link_cross_device_linking_text, + providerName + ) + + override val emailLinkInvalidLinkTitle: String + get() = localizedContext.getString(R.string.fui_email_link_invalid_link_header) + + override val emailLinkInvalidLinkMessage: String + get() = localizedContext.getString(R.string.fui_email_link_invalid_link_message) + + override val emailMismatchMessage: String + get() = localizedContext.getString(R.string.fui_error_unknown) + + /** + * Phone Authentication Strings + */ + override val verifyPhoneNumberTitle: String + get() = localizedContext.getString(R.string.fui_verify_phone_number_title) + override val phoneHint: String + get() = localizedContext.getString(R.string.fui_phone_hint) + override val countryHint: String + get() = localizedContext.getString(R.string.fui_country_hint) + override val invalidPhoneNumber: String + get() = localizedContext.getString(R.string.fui_invalid_phone_number) + override val missingPhoneNumber: String + get() = localizedContext.getString(R.string.fui_required_field) + override val enterConfirmationCode: String + get() = localizedContext.getString(R.string.fui_enter_confirmation_code) + override val verifyPhoneNumber: String + get() = localizedContext.getString(R.string.fui_verify_phone_number) + override val resendCodeIn: String + get() = localizedContext.getString(R.string.fui_resend_code_in) + override val resendCode: String + get() = localizedContext.getString(R.string.fui_resend_code) + + override fun resendCodeTimer(timeFormatted: String): String = + localizedContext.getString(R.string.fui_resend_code_in, timeFormatted) + + override val verifying: String + get() = localizedContext.getString(R.string.fui_verifying) + override val incorrectCodeDialogBody: String + get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) + override val smsTermsOfService: String + get() = localizedContext.getString(R.string.fui_sms_terms_of_service) + + override val enterPhoneNumberTitle: String + get() = localizedContext.getString(R.string.fui_verify_phone_number_title) + + override val phoneNumberHint: String + get() = localizedContext.getString(R.string.fui_phone_hint) + + override val sendVerificationCode: String + get() = localizedContext.getString(R.string.fui_next_default) + + override fun enterVerificationCodeTitle(phoneNumber: String): String = + localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber + + override val verificationCodeHint: String + get() = localizedContext.getString(R.string.fui_enter_confirmation_code) + + override val changePhoneNumber: String + get() = localizedContext.getString(R.string.fui_change_phone_number) + + override val missingVerificationCode: String + get() = localizedContext.getString(R.string.fui_required_field) + + override val invalidVerificationCode: String + get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) + + override val countrySelectorModalTitle: String + get() = localizedContext.getString(R.string.fui_country_selector_title) + + override val searchCountriesHint: String + get() = localizedContext.getString(R.string.fui_search_country_field_hint) + + /** + * Multi-Factor Authentication Strings + */ + override val enterTOTPCode: String + get() = "Enter TOTP Code" + + /** + * Provider Picker Strings + */ + override val signInDefault: String + get() = localizedContext.getString(R.string.fui_sign_in_default) + override val continueText: String + get() = localizedContext.getString(R.string.fui_continue) + override val nextDefault: String + get() = localizedContext.getString(R.string.fui_next_default) + + /** + * General Error Messages + */ + override val errorUnknown: String + get() = localizedContext.getString(R.string.fui_error_unknown) + override val requiredField: String + get() = localizedContext.getString(R.string.fui_required_field) + override val progressDialogLoading: String + get() = localizedContext.getString(R.string.fui_progress_dialog_loading) + + override fun signedInAs(userIdentifier: String): String = + localizedContext.getString(R.string.fui_signed_in_as, userIdentifier) + + override val manageMfaAction: String + get() = localizedContext.getString(R.string.fui_manage_mfa_action) + + override val signOutAction: String + get() = localizedContext.getString(R.string.fui_sign_out_action) + + override fun verifyEmailInstruction(email: String): String = + localizedContext.getString(R.string.fui_verify_email_instruction, email) + + override val resendVerificationEmailAction: String + get() = localizedContext.getString(R.string.fui_resend_verification_email_action) + + override val verifiedEmailAction: String + get() = localizedContext.getString(R.string.fui_verified_email_action) + + override val profileCompletionMessage: String + get() = localizedContext.getString(R.string.fui_profile_completion_message) + + override fun profileMissingFieldsMessage(fields: String): String = + localizedContext.getString(R.string.fui_profile_missing_fields_message, fields) + + override val skipAction: String + get() = localizedContext.getString(R.string.fui_skip_action) + + override val removeAction: String + get() = localizedContext.getString(R.string.fui_remove_action) + + override val backAction: String + get() = localizedContext.getString(R.string.fui_back_action) + + override val verifyAction: String + get() = localizedContext.getString(R.string.fui_verify_action) + + override val useDifferentMethodAction: String + get() = localizedContext.getString(R.string.fui_use_different_method_action) + + override val recoveryCodesSavedAction: String + get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action) + + override val secretKeyLabel: String + get() = localizedContext.getString(R.string.fui_secret_key_label) + + override val verificationCodeLabel: String + get() = localizedContext.getString(R.string.fui_verification_code_label) + + override val identityVerifiedMessage: String + get() = localizedContext.getString(R.string.fui_identity_verified_message) + + override val mfaManageFactorsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_manage_factors_title) + + override val mfaManageFactorsDescription: String + get() = localizedContext.getString(R.string.fui_mfa_manage_factors_description) + + override val mfaActiveMethodsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_active_methods_title) + + override val mfaAddNewMethodTitle: String + get() = localizedContext.getString(R.string.fui_mfa_add_new_method_title) + + override val mfaAllMethodsEnrolledMessage: String + get() = localizedContext.getString(R.string.fui_mfa_all_methods_enrolled_message) + + override val smsAuthenticationLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_sms_authentication) + + override val totpAuthenticationLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_totp_authentication) + + override val unknownMethodLabel: String + get() = localizedContext.getString(R.string.fui_mfa_label_unknown_method) + + override fun enrolledOnDateLabel(date: String): String = + localizedContext.getString(R.string.fui_mfa_enrolled_on, date) + + override val setupAuthenticatorDescription: String + get() = localizedContext.getString(R.string.fui_mfa_setup_authenticator_description) + override val noInternet: String + get() = localizedContext.getString(R.string.fui_no_internet) + + /** + * Error Recovery Dialog Strings + */ + override val errorDialogTitle: String + get() = localizedContext.getString(R.string.fui_error_dialog_title) + override val retryAction: String + get() = localizedContext.getString(R.string.fui_error_retry_action) + override val dismissAction: String + get() = localizedContext.getString(R.string.fui_email_link_dismiss_button) + override val networkErrorRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_no_internet) + override val invalidCredentialsRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_invalid_password) + override val userNotFoundRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_email_does_not_exist) + override val weakPasswordRecoveryMessage: String + get() = localizedContext.resources.getQuantityString( + R.plurals.fui_error_weak_password, + 6, + 6 + ) + override val emailAlreadyInUseRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_email_account_creation_error) + override val tooManyRequestsRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_too_many_attempts) + override val mfaRequiredRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_mfa_required_message) + override val accountLinkingRequiredRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_account_linking_required_message) + override val authCancelledRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_auth_cancelled_message) + override val unknownErrorRecoveryMessage: String + get() = localizedContext.getString(R.string.fui_error_unknown) + + /** + * MFA Enrollment Step Titles + */ + override val mfaStepSelectFactorTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_title) + override val mfaStepConfigureSmsTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_title) + override val mfaStepConfigureTotpTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title) + override val mfaStepVerifyFactorTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_title) + override val mfaStepShowRecoveryCodesTitle: String + get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_title) + + /** + * MFA Enrollment Helper Text + */ + override val mfaStepSelectFactorHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_helper) + override val mfaStepConfigureSmsHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_helper) + override val mfaStepConfigureTotpHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_helper) + override val mfaStepVerifyFactorSmsHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_sms_helper) + override val mfaStepVerifyFactorTotpHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper) + override val mfaStepVerifyFactorGenericHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_generic_helper) + override val mfaStepShowRecoveryCodesHelper: String + get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper) + + // MFA Enrollment Screen Titles + override val mfaEnrollmentEnterPhoneNumber: String + get() = localizedContext.getString(R.string.fui_mfa_enrollment_enter_phone_number) + override val mfaEnrollmentVerifySmsCode: String + get() = localizedContext.getString(R.string.fui_mfa_enrollment_verify_sms_code) + + // MFA Error Messages + override val mfaErrorRecentLoginRequired: String + get() = localizedContext.getString(R.string.fui_mfa_error_recent_login_required) + override val mfaErrorInvalidVerificationCode: String + get() = localizedContext.getString(R.string.fui_mfa_error_invalid_verification_code) + override val mfaErrorNetwork: String + get() = localizedContext.getString(R.string.fui_mfa_error_network) + override val mfaErrorGeneric: String + get() = localizedContext.getString(R.string.fui_mfa_error_generic) + + override val reauthDialogTitle: String + get() = localizedContext.getString(R.string.fui_reauth_dialog_title) + + override val reauthDialogMessage: String + get() = localizedContext.getString(R.string.fui_reauth_dialog_message) + + override fun reauthAccountLabel(email: String): String = + localizedContext.getString(R.string.fui_reauth_account_label, email) + + override val incorrectPasswordError: String + get() = localizedContext.getString(R.string.fui_incorrect_password_error) + + override val reauthGenericError: String + get() = localizedContext.getString(R.string.fui_reauth_generic_error) + + override val termsOfService: String + get() = localizedContext.getString(R.string.fui_terms_of_service) + + override val privacyPolicy: String + get() = localizedContext.getString(R.string.fui_privacy_policy) + + override fun tosAndPrivacyPolicy(termsOfServiceLabel: String, privacyPolicyLabel: String): String = + localizedContext.getString(R.string.fui_tos_and_pp, termsOfServiceLabel, privacyPolicyLabel) + + override val newAccountsDisabledTooltip: String + get() = localizedContext.getString(R.string.fui_new_accounts_disabled_tooltip) + + override val mfaDisabledTooltip: String + get() = localizedContext.getString(R.string.fui_mfa_disabled_tooltip) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt new file mode 100644 index 0000000000..c59fcf66e4 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.theme + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource + +/** + * Represents a visual asset used in the authentication UI. + * + * This sealed class allows specifying icons and images from either Android drawable + * resources ([Resource]) or Jetpack Compose [ImageVector]s ([Vector]). The [painter] + * property provides a unified way to get a [Painter] for the asset within a composable. + * + * **Example usage:** + * ```kotlin + * // To use a drawable resource: + * val asset = AuthUIAsset.Resource(R.drawable.my_logo) + * + * // To use a vector asset: + * val vectorAsset = AuthUIAsset.Vector(Icons.Default.Info) + * ``` + */ +sealed class AuthUIAsset { + /** + * An asset loaded from a drawable resource. + * + * @param resId The resource ID of the drawable (e.g., `R.drawable.my_icon`). + */ + class Resource(@param:DrawableRes val resId: Int) : AuthUIAsset() + + /** + * An asset represented by an [ImageVector]. + * + * @param image The [ImageVector] to be displayed. + */ + class Vector(val image: ImageVector) : AuthUIAsset() + + /** + * A [Painter] that can be used to draw this asset in a composable. + * + * This property automatically resolves the asset type and returns the appropriate + * [Painter] for rendering. + */ + @get:Composable + internal val painter: Painter + get() = when (this) { + is Resource -> painterResource(resId) + is Vector -> rememberVectorPainter(image) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt new file mode 100644 index 0000000000..79e78f80f2 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * CompositionLocal providing access to the current AuthUITheme. + * This allows components to access theme configuration including provider styles and shapes. + */ +val LocalAuthUITheme = staticCompositionLocalOf { AuthUITheme.Default } + +/** + * Theming configuration for the entire Auth UI. + */ +class AuthUITheme( + /** + * The color scheme to use. + */ + val colorScheme: ColorScheme, + + /** + * The typography to use. + */ + val typography: Typography, + + /** + * The shapes to use for UI elements (text fields, cards, etc.). + */ + val shapes: Shapes, + + /** + * A map of provider IDs to custom styling. Use this to customize individual + * provider buttons with specific colors, icons, shapes, and elevation. + * + * Example: + * ```kotlin + * providerStyles = mapOf( + * "google.com" to ProviderStyleDefaults.Google.copy( + * shape = RoundedCornerShape(12.dp) + * ) + * ) + * ``` + */ + val providerStyles: Map = emptyMap(), + + /** + * Default shape for all provider buttons. If not set, defaults to RoundedCornerShape(4.dp). + * Individual provider styles can override this shape. + * + * Example: + * ```kotlin + * providerButtonShape = RoundedCornerShape(12.dp) + * ``` + */ + val providerButtonShape: Shape? = null, +) { + + /** + * Creates a copy of this AuthUITheme, optionally overriding specific properties. + * + * @param colorScheme The color scheme to use. Defaults to this theme's color scheme. + * @param typography The typography to use. Defaults to this theme's typography. + * @param shapes The shapes to use. Defaults to this theme's shapes. + * @param providerStyles Custom styling for individual providers. Defaults to this theme's provider styles. + * @param providerButtonShape Default shape for provider buttons. Defaults to this theme's provider button shape. + * @return A new AuthUITheme instance with the specified properties. + */ + fun copy( + colorScheme: ColorScheme = this.colorScheme, + typography: Typography = this.typography, + shapes: Shapes = this.shapes, + providerStyles: Map = this.providerStyles, + providerButtonShape: Shape? = this.providerButtonShape, + ): AuthUITheme { + return AuthUITheme( + colorScheme = colorScheme, + typography = typography, + shapes = shapes, + providerStyles = providerStyles, + providerButtonShape = providerButtonShape + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AuthUITheme) return false + + if (colorScheme != other.colorScheme) return false + if (typography != other.typography) return false + if (shapes != other.shapes) return false + if (providerStyles != other.providerStyles) return false + if (providerButtonShape != other.providerButtonShape) return false + + return true + } + + override fun hashCode(): Int { + var result = colorScheme.hashCode() + result = 31 * result + typography.hashCode() + result = 31 * result + shapes.hashCode() + result = 31 * result + providerStyles.hashCode() + result = 31 * result + (providerButtonShape?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "AuthUITheme(colorScheme=$colorScheme, typography=$typography, shapes=$shapes, " + + "providerStyles=$providerStyles, providerButtonShape=$providerButtonShape)" + } + + /** + * A class nested within AuthUITheme that defines the visual appearance of a specific + * provider button, allowing for per-provider branding and customization. + */ + data class ProviderStyle( + /** + * The provider's icon. + */ + val icon: AuthUIAsset?, + + /** + * The background color of the button. + */ + val backgroundColor: Color, + + /** + * The color of the text label on the button. + */ + val contentColor: Color, + + /** + * An optional tint color for the provider's icon. If null, + * the icon's intrinsic color is used. + */ + val iconTint: Color? = null, + + /** + * The shape of the button container. If null, uses the theme's providerButtonShape + * or falls back to RoundedCornerShape(4.dp). + */ + val shape: Shape? = null, + + /** + * The shadow elevation for the button. Defaults to 2.dp. + */ + val elevation: Dp = 2.dp, + ) { + internal companion object { + /** + * A fallback style for unknown providers with no icon, white background, + * and black text. + */ + val Empty = ProviderStyle( + icon = null, + backgroundColor = Color.White, + contentColor = Color.Black, + ) + } + } + + companion object { + /** + * A standard light theme with Material 3 defaults and + * pre-configured provider styles. + */ + val Default = AuthUITheme( + colorScheme = lightColorScheme(), + typography = Typography(), + shapes = Shapes(), + providerStyles = ProviderStyleDefaults.default + ) + + val DefaultDark = AuthUITheme( + colorScheme = darkColorScheme(), + typography = Typography(), + shapes = Shapes(), + providerStyles = ProviderStyleDefaults.default + ) + + val Adaptive: AuthUITheme + @Composable get() = if (isSystemInDarkTheme()) DefaultDark else Default + + /** + * Creates a theme inheriting the app's current Material Theme settings. + * + * @param providerStyles Custom styling for individual providers. Defaults to standard provider styles. + * @param providerButtonShape Default shape for all provider buttons. If null, uses RoundedCornerShape(4.dp). + */ + @Composable + fun fromMaterialTheme( + providerStyles: Map = ProviderStyleDefaults.default, + providerButtonShape: Shape? = null, + ): AuthUITheme { + return AuthUITheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + providerStyles = providerStyles, + providerButtonShape = providerButtonShape + ) + } + + @OptIn(ExperimentalMaterial3Api::class) + @get:Composable + val topAppBarColors + get() = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +fun AuthUITheme( + theme: AuthUITheme = AuthUITheme.Adaptive, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalAuthUITheme provides theme + ) { + MaterialTheme( + colorScheme = theme.colorScheme, + typography = theme.typography, + shapes = theme.shapes, + content = content + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt new file mode 100644 index 0000000000..c4721b395c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.theme + +import androidx.compose.ui.graphics.Color +import com.firebase.ui.auth.R +import com.firebase.ui.auth.configuration.auth_provider.Provider + +/** + * Default provider styling configurations for authentication providers. + * + * This object provides brand-appropriate visual styling for each supported authentication + * provider, including background colors, text colors, and other visual properties that + * match each provider's brand guidelines. + * + * The styles are automatically applied when using [AuthUITheme.Default] or can be + * customized by passing a modified map to [AuthUITheme.fromMaterialTheme]. + * + * Individual provider styles can be accessed and customized using the public properties + * (e.g., [Google], [Facebook]) and then modified using the [AuthUITheme.ProviderStyle.copy] method. + */ +object ProviderStyleDefaults { + val Google = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_googleg_color_24dp), + backgroundColor = Color.White, + contentColor = Color(0xFF757575) + ) + + val Facebook = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp), + backgroundColor = Color(0xFF1877F2), + contentColor = Color.White + ) + + val Twitter = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_x_white_24dp), + backgroundColor = Color.Black, + contentColor = Color.White + ) + + val Github = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp), + backgroundColor = Color(0xFF24292E), + contentColor = Color.White + ) + + val Email = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp), + backgroundColor = Color(0xFFD0021B), + contentColor = Color.White + ) + + val Phone = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp), + backgroundColor = Color(0xFF43C5A5), + contentColor = Color.White + ) + + val Anonymous = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp), + backgroundColor = Color(0xFFF4B400), + contentColor = Color.White + ) + + val Microsoft = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp), + backgroundColor = Color(0xFF2F2F2F), + contentColor = Color.White + ) + + val Yahoo = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp), + backgroundColor = Color(0xFF720E9E), + contentColor = Color.White + ) + + val Apple = AuthUITheme.ProviderStyle( + icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp), + backgroundColor = Color.Black, + contentColor = Color.White + ) + + val default: Map + get() = mapOf( + Provider.GOOGLE.id to Google, + Provider.FACEBOOK.id to Facebook, + Provider.TWITTER.id to Twitter, + Provider.GITHUB.id to Github, + Provider.EMAIL.id to Email, + Provider.PHONE.id to Phone, + Provider.ANONYMOUS.id to Anonymous, + Provider.MICROSOFT.id to Microsoft, + Provider.YAHOO.id to Yahoo, + Provider.APPLE.id to Apple + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt new file mode 100644 index 0000000000..0bcee25bcd --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingEmailAddress + ) + return false + } + + if (!android.util.Patterns.EMAIL_ADDRESS.matcher(value).matches()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidEmailAddress + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt new file mode 100644 index 0000000000..a72313560c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +/** + * Class for encapsulating [hasError] and [errorMessage] properties in + * internal FieldValidator subclasses. + */ +internal class FieldValidationStatus( + val hasError: Boolean, + val errorMessage: String? = null, +) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt new file mode 100644 index 0000000000..4a6924f507 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +/** + * An interface for validating input fields. + */ +interface FieldValidator { + val stringProvider: AuthUIStringProvider + + /** + * Returns true if the last validation failed. + */ + val hasError: Boolean + + /** + * The error message for the current state. + */ + val errorMessage: String + + /** + * Runs validation on a value and returns true if valid. + */ + fun validate(value: String): Boolean +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt new file mode 100644 index 0000000000..a72c9f80df --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +internal class GeneralFieldValidator( + override val stringProvider: AuthUIStringProvider, + val isValid: ((String) -> Boolean)? = null, + val customMessage: String? = null, +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.requiredField + ) + return false + } + + if (isValid != null && !isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = customMessage + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt new file mode 100644 index 0000000000..b7a8a70eb6 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.PasswordRule +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +internal class PasswordValidator( + override val stringProvider: AuthUIStringProvider, + private val rules: List +) : FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPassword + ) + return false + } + + for (rule in rules) { + if (!rule.isValid(value)) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = rule.getErrorMessage(stringProvider) + ) + return false + } + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt new file mode 100644 index 0000000000..1d2484bbc4 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.data.CountryData +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil + +internal class PhoneNumberValidator( + override val stringProvider: AuthUIStringProvider, + val selectedCountry: CountryData, +) : + FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + private val phoneNumberUtil = PhoneNumberUtil.getInstance() + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingPhoneNumber + ) + return false + } + + try { + val phoneNumber = phoneNumberUtil.parse(value, selectedCountry.countryCode) + val isValid = phoneNumberUtil.isValidNumber(phoneNumber) + + if (!isValid) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPhoneNumber + ) + return false + } + } catch (_: NumberParseException) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidPhoneNumber + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt new file mode 100644 index 0000000000..9824a81618 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.configuration.validators + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +internal class VerificationCodeValidator(override val stringProvider: AuthUIStringProvider) : + FieldValidator { + private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + + override val hasError: Boolean + get() = _validationStatus.hasError + + override val errorMessage: String + get() = _validationStatus.errorMessage ?: "" + + override fun validate(value: String): Boolean { + if (value.isEmpty()) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.missingVerificationCode + ) + return false + } + + // Verification codes are typically 6 digits + val digitsOnly = value.replace(Regex("[^0-9]"), "") + if (digitsOnly.length != 6) { + _validationStatus = FieldValidationStatus( + hasError = true, + errorMessage = stringProvider.invalidVerificationCode + ) + return false + } + + _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) + return true + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt new file mode 100644 index 0000000000..535ace99d7 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.credentialmanager + +/** + * Represents a password credential retrieved from the system credential manager. + * + * @property username The username/identifier associated with the credential + * @property password The password associated with the credential + */ +data class PasswordCredential( + val username: String, + val password: String +) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt new file mode 100644 index 0000000000..c83f9a280c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.credentialmanager + +import android.content.Context +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential as AndroidPasswordCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.firebase.ui.auth.util.CredentialPersistenceManager + +/** + * Provider interface for obtaining CredentialManager instances. + * This allows test code to inject mock CredentialManager instances. + */ +interface CredentialManagerProvider { + fun getCredentialManager(context: Context): CredentialManager +} + +/** + * Default implementation that creates a real CredentialManager instance. + */ +class DefaultCredentialManagerProvider : CredentialManagerProvider { + override fun getCredentialManager(context: Context): CredentialManager { + return CredentialManager.create(context) + } +} + +/** + * Handler for password credential operations using Android's Credential Manager. + * + * This class provides methods to save and retrieve password credentials through + * the system credential manager, which displays native UI prompts to the user. + * + * @property context The Android context used for credential operations + * @property provider Optional provider for testing purposes + */ +class PasswordCredentialHandler( + private val context: Context, + provider: CredentialManagerProvider? = null +) { + companion object { + /** + * Test-only provider for injecting mock CredentialManager instances. + * Set this in your test setup to override the default CredentialManager. + * + * Example: + * ``` + * PasswordCredentialHandler.testCredentialManagerProvider = object : CredentialManagerProvider { + * override fun getCredentialManager(context: Context) = mockCredentialManager + * } + * ``` + */ + @Volatile + var testCredentialManagerProvider: CredentialManagerProvider? = null + + /** + * Checks if credentials have been saved at least once. + * This prevents unnecessary credential retrieval attempts. + * + * @param context The Android context + * @return true if credentials have been saved, false otherwise + */ + suspend fun hasSavedCredentials(context: Context): Boolean { + return CredentialPersistenceManager.hasSavedCredentials(context) + } + + /** + * Clears the saved credentials flag. + * Useful for testing or when user signs out permanently. + * + * @param context The Android context + */ + suspend fun clearSavedCredentialsFlag(context: Context) { + CredentialPersistenceManager.clearSavedCredentialsFlag(context) + } + } + + private val credentialManager: CredentialManager = + provider?.getCredentialManager(context) + ?: testCredentialManagerProvider?.getCredentialManager(context) + ?: CredentialManager.create(context) + + /** + * Saves a password credential to the system credential manager. + * + * This method displays a system prompt to the user asking if they want to save + * the credential. The operation is performed asynchronously using Kotlin coroutines. + * + * @param username The username/identifier for the credential + * @param password The password to save + * @throws CreateCredentialException if the credential cannot be saved + * @throws CreateCredentialCancellationException if the user cancels the save operation + * @throws IllegalArgumentException if username or password is blank + */ + suspend fun savePassword(username: String, password: String) { + require(username.isNotBlank()) { "Username cannot be blank" } + require(password.isNotBlank()) { "Password cannot be blank" } + + val request = CreatePasswordRequest( + id = username, + password = password + ) + + try { + credentialManager.createCredential(context, request) + // Mark that credentials have been saved successfully + CredentialPersistenceManager.setCredentialsSaved(context) + } catch (e: CreateCredentialCancellationException) { + // User cancelled the save operation + throw PasswordCredentialCancelledException("User cancelled password save operation", e) + } catch (e: CreateCredentialException) { + // Other credential creation errors + throw PasswordCredentialException("Failed to save password credential", e) + } + } + + /** + * Retrieves a password credential from the system credential manager. + * + * This method displays a system prompt showing available credentials for the user + * to select from. The operation is performed asynchronously using Kotlin coroutines. + * + * @return PasswordCredential containing the username and password + * @throws NoCredentialException if no credentials are available + * @throws GetCredentialCancellationException if the user cancels the retrieval operation + * @throws GetCredentialException if the credential cannot be retrieved + */ + suspend fun getPassword(): PasswordCredential { + val getPasswordOption = GetPasswordOption() + val request = GetCredentialRequest.Builder() + .addCredentialOption(getPasswordOption) + .build() + + try { + val result = credentialManager.getCredential(context, request) + val credential = result.credential + + if (credential is AndroidPasswordCredential) { + return PasswordCredential( + username = credential.id, + password = credential.password + ) + } else { + throw PasswordCredentialException("Retrieved credential is not a password credential") + } + } catch (e: GetCredentialCancellationException) { + // User cancelled the retrieval operation + throw PasswordCredentialCancelledException("User cancelled password retrieval operation", e) + } catch (e: NoCredentialException) { + // No credentials available + throw PasswordCredentialNotFoundException("No password credentials found", e) + } catch (e: GetCredentialException) { + // Other credential retrieval errors + throw PasswordCredentialException("Failed to retrieve password credential", e) + } + } +} + +/** + * Base exception for password credential operations. + */ +open class PasswordCredentialException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** + * Exception thrown when a password credential operation is cancelled by the user. + */ +class PasswordCredentialCancelledException( + message: String, + cause: Throwable? = null +) : PasswordCredentialException(message, cause) + +/** + * Exception thrown when no password credentials are found. + */ +class PasswordCredentialNotFoundException( + message: String, + cause: Throwable? = null +) : PasswordCredentialException(message, cause) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt new file mode 100644 index 0000000000..e6400cc600 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.data + +/** + * Complete list of countries with their dial codes and ISO country codes. + * Auto-generated from ISO 3166-1 standard. + */ +val ALL_COUNTRIES: List = listOf( + CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")), + CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")), + CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")), + CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")), + CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")), + CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")), + CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")), + CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")), + CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")), + CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")), + CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")), + CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")), + CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")), + CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")), + CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")), + CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")), + CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")), + CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")), + CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")), + CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")), + CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")), + CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")), + CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")), + CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")), + CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")), + CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")), + CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")), + CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")), + CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")), + CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")), + CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")), + CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")), + CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")), + CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")), + CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")), + CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")), + CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")), + CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")), + CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")), + CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")), + CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")), + CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")), + CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")), + CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")), + CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")), + CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")), + CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")), + CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")), + CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")), + CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")), + CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")), + CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")), + CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")), + CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")), + CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")), + CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")), + CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")), + CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")), + CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")), + CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")), + CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")), + CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")), + CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")), + CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")), + CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")), + CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")), + CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")), + CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")), + CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")), + CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")), + CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")), + CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")), + CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")), + CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")), + CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")), + CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")), + CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")), + CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")), + CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")), + CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")), + CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")), + CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")), + CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")), + CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")), + CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")), + CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")), + CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")), + CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")), + CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")), + CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")), + CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")), + CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")), + CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")), + CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")), + CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")), + CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")), + CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")), + CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")), + CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")), + CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")), + CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")), + CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")), + CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")), + CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")), + CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")), + CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")), + CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")), + CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")), + CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")), + CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")), + CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")), + CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")), + CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")), + CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")), + CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")), + CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")), + CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")), + CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")), + CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")), + CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")), + CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")), + CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")), + CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")), + CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")), + CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")), + CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")), + CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")), + CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")), + CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")), + CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")), + CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")), + CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")), + CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")), + CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")), + CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")), + CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")), + CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")), + CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")), + CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")), + CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")), + CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")), + CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")), + CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")), + CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")), + CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")), + CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")), + CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")), + CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")), + CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")), + CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")), + CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")), + CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")), + CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")), + CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")), + CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")), + CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")), + CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")), + CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")), + CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")), + CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")), + CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")), + CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")), + CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")), + CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")), + CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")), + CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")), + CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")), + CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")), + CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")), + CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")), + CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")), + CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")), + CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")), + CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")), + CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")), + CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")), + CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")), + CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")), + CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")), + CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")), + CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")), + CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")), + CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")), + CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")), + CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")), + CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")), + CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")), + CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")), + CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")), + CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")), + CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")), + CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")), + CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")), + CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")), + CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")), + CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")), + CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")), + CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")), + CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")), + CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")), + CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")), + CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")), + CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")), + CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")), + CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")), + CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")), + CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")), + CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")), + CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")), + CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")), + CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")), + CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")), + CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")), + CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")), + CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")), + CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")), + CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")), + CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")), + CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")), + CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")), + CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")), + CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")), + CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")), + CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")), + CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")), + CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")), + CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")), + CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")), + CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")), + CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")), + CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")), + CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")), + CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")), + CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")), + CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")), + CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")), + CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")), + CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW")) +) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt new file mode 100644 index 0000000000..e171f47a8c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.data + +/** + * Represents country information for phone number authentication. + * + * @property name The display name of the country (e.g., "United States"). + * @property dialCode The international dialing code (e.g., "+1"). + * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US"). + * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸"). + */ +data class CountryData( + val name: String, + val dialCode: String, + val countryCode: String, + val flagEmoji: String +) { + /** + * Returns a formatted display string combining flag emoji and country name. + */ + fun getDisplayName(): String = "$flagEmoji $name" + + /** + * Returns a formatted string with dial code. + */ + fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" +} + +/** + * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. + * + * @param countryCode The two-letter country code (e.g., "US", "GB", "FR"). + * @return The flag emoji string, or an empty string if the code is invalid. + */ +fun countryCodeToFlagEmoji(countryCode: String): String { + if (countryCode.length != 2) return "" + + val uppercaseCode = countryCode.uppercase() + val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A + val charCodeOffset = 'A'.code + + val firstChar = uppercaseCode[0].code + val secondChar = uppercaseCode[1].code + + val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset) + val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset) + + return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt new file mode 100644 index 0000000000..4311a7926f --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import com.firebase.ui.auth.configuration.MfaFactor + +/** + * State class containing all the necessary information to render a custom UI for the + * Multi-Factor Authentication (MFA) challenge flow during sign-in. + * + * This class is passed to the content slot of the MfaChallengeScreen composable, providing + * access to the current factor, user input values, callbacks for actions, and loading/error states. + * + * The challenge flow is simpler than enrollment as the user has already configured their MFA: + * 1. User enters their verification code (SMS or TOTP) + * 2. System verifies the code and completes sign-in + * + * ```kotlin + * MfaChallengeScreen(resolver, onSuccess, onCancel, onError) { state -> + * Column { + * Text("Enter your ${state.factorType} code") + * TextField( + * value = state.verificationCode, + * onValueChange = state.onVerificationCodeChange + * ) + * if (state.canResend) { + * TextButton(onClick = state.onResendCodeClick) { + * Text("Resend code") + * } + * } + * Button( + * onClick = state.onVerifyClick, + * enabled = !state.isLoading && state.isValid + * ) { + * Text("Verify") + * } + * } + * } + * ``` + * + * @property factorType The type of MFA factor being challenged (SMS or TOTP) + * @property maskedPhoneNumber For SMS factors, the masked phone number (e.g., "+1••••••890") + * @property isLoading `true` when verification is in progress. Use this to show loading indicators. + * @property error An optional error message to display to the user. Will be `null` if there's no error. + * @property verificationCode The current value of the verification code input field. + * @property resendTimer The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. + * @property onVerificationCodeChange Callback invoked when the verification code input changes. + * @property onVerifyClick Callback to verify the entered code and complete sign-in. + * @property onResendCodeClick For SMS only: Callback to resend the verification code. `null` for TOTP. + * @property onCancelClick Callback to cancel the MFA challenge and return to sign-in. + * + * @since 10.0.0 + */ +data class MfaChallengeContentState( + /** The type of MFA factor being challenged (SMS or TOTP). */ + val factorType: MfaFactor, + + /** For SMS: the masked phone number. For TOTP: null. */ + val maskedPhoneNumber: String? = null, + + /** `true` when verification is in progress. Use to show loading indicators. */ + val isLoading: Boolean = false, + + /** Optional error message to display. `null` if no error. */ + val error: String? = null, + + /** The current value of the verification code input field. */ + val verificationCode: String = "", + + /** The number of seconds remaining before resend is available. 0 when ready. */ + val resendTimer: Int = 0, + + /** Callback invoked when the verification code input changes. */ + val onVerificationCodeChange: (String) -> Unit = {}, + + /** Callback to verify the code and complete sign-in. */ + val onVerifyClick: () -> Unit = {}, + + /** For SMS only: Callback to resend the code. `null` for TOTP. */ + val onResendCodeClick: (() -> Unit)? = null, + + /** Callback to cancel the challenge and return to sign-in. */ + val onCancelClick: () -> Unit = {} +) { + /** + * Returns true if the current state is valid for verification. + * The code must be 6 digits long. + */ + val isValid: Boolean + get() = verificationCode.length == 6 && verificationCode.all { it.isDigit() } + + /** + * Returns true if there is an error in the current state. + */ + val hasError: Boolean + get() = !error.isNullOrBlank() + + /** + * Returns true if the resend action is available (SMS only). + */ + val canResend: Boolean + get() = factorType == MfaFactor.Sms && onResendCodeClick != null +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt new file mode 100644 index 0000000000..674cb42e60 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.data.CountryData +import com.google.firebase.auth.MultiFactorInfo + +/** + * State class containing all the necessary information to render a custom UI for the + * Multi-Factor Authentication (MFA) enrollment flow. + * + * This class is passed to the content slot of the MfaEnrollmentScreen composable, providing + * access to the current step, user input values, callbacks for actions, and loading/error states. + * + * Use a `when` expression on [step] to determine which UI to render: + * + * ```kotlin + * MfaEnrollmentScreen(user, config, onComplete, onSkip) { state -> + * when (state.step) { + * MfaEnrollmentStep.SelectFactor -> { + * // Render factor selection UI using state.availableFactors + * } + * MfaEnrollmentStep.ConfigureTotp -> { + * // Render TOTP setup UI using state.totpSecret and state.totpQrCodeUrl + * } + * MfaEnrollmentStep.VerifyFactor -> { + * // Render verification UI using state.verificationCode + * } + * // ... other steps + * } + * } + * ``` + * + * @property step The current step in the enrollment flow. Use this to determine which UI to display. + * @property isLoading `true` when an asynchronous operation (like generating a secret or verifying a code) is in progress. Use this to show loading indicators. + * @property error An optional error message to display to the user. Will be `null` if there's no error. + * @property onBackClick Callback to navigate to the previous step in the flow. Invoked when the user clicks a back button. + * + * @property availableFactors (Step: [MfaEnrollmentStep.SelectFactor]) A list of MFA factors the user can choose from (e.g., SMS, TOTP). Determined by [com.firebase.ui.auth.configuration.MfaConfiguration.allowedFactors]. + * @property onFactorSelected (Step: [MfaEnrollmentStep.SelectFactor]) Callback invoked when the user selects an MFA factor. Receives the selected [MfaFactor]. + * @property onSkipClick (Step: [MfaEnrollmentStep.SelectFactor]) Callback for the "Skip" action. Will be `null` if MFA enrollment is required via [com.firebase.ui.auth.configuration.MfaConfiguration.requireEnrollment]. + * + * @property phoneNumber (Step: [MfaEnrollmentStep.ConfigureSms]) The current value of the phone number input field. Does not include country code prefix. + * @property onPhoneNumberChange (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the phone number input changes. Receives the new phone number string. + * @property selectedCountry (Step: [MfaEnrollmentStep.ConfigureSms]) The currently selected country for phone number formatting. Contains dial code, country code, and flag. + * @property onCountrySelected (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the user selects a different country. Receives the new [CountryData]. + * @property onSendSmsCodeClick (Step: [MfaEnrollmentStep.ConfigureSms]) Callback to send the SMS verification code to the entered phone number. + * + * @property totpSecret (Step: [MfaEnrollmentStep.ConfigureTotp]) The TOTP secret containing the shared key and configuration. Use this to display the secret key or access the underlying Firebase TOTP secret. + * @property totpQrCodeUrl (Step: [MfaEnrollmentStep.ConfigureTotp]) A URI that can be rendered as a QR code or used as a deep link to open authenticator apps. Generated via [TotpSecret.generateQrCodeUrl]. + * @property onContinueToVerifyClick (Step: [MfaEnrollmentStep.ConfigureTotp]) Callback to proceed to the verification step after the user has scanned the QR code or entered the secret. + * + * @property verificationCode (Step: [MfaEnrollmentStep.VerifyFactor]) The current value of the verification code input field. Should be a 6-digit string. + * @property onVerificationCodeChange (Step: [MfaEnrollmentStep.VerifyFactor]) Callback invoked when the verification code input changes. Receives the new code string. + * @property onVerifyClick (Step: [MfaEnrollmentStep.VerifyFactor]) Callback to verify the entered code and finalize MFA enrollment. + * @property selectedFactor (Step: [MfaEnrollmentStep.VerifyFactor]) The MFA factor being verified (SMS or TOTP). Use this to customize UI messages. + * @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. + * @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification. + * + * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.configuration.MfaConfiguration.enableRecoveryCodes] is `true`. + * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow. + * + * @since 10.0.0 + */ +data class MfaEnrollmentContentState( + /** The current step in the enrollment flow. Use this to determine which UI to display. */ + val step: MfaEnrollmentStep, + + /** `true` when an async operation is in progress. Use to show loading indicators. */ + val isLoading: Boolean = false, + + /** Optional error message to display. `null` if no error. */ + val error: String? = null, + + /** The last exception encountered during enrollment, if available. */ + val exception: Exception? = null, + + /** Callback to navigate to the previous step. */ + val onBackClick: () -> Unit = {}, + + // SelectFactor step + val availableFactors: List = emptyList(), + + val enrolledFactors: List = emptyList(), + + val onFactorSelected: (MfaFactor) -> Unit = {}, + + val onUnenrollFactor: (MultiFactorInfo) -> Unit = {}, + + val onSkipClick: (() -> Unit)? = null, + + // ConfigureSms step + val phoneNumber: String = "", + + val onPhoneNumberChange: (String) -> Unit = {}, + + val selectedCountry: CountryData? = null, + + val onCountrySelected: (CountryData) -> Unit = {}, + + val onSendSmsCodeClick: () -> Unit = {}, + + // ConfigureTotp step + val totpSecret: TotpSecret? = null, + + val totpQrCodeUrl: String? = null, + + val onContinueToVerifyClick: () -> Unit = {}, + + // VerifyFactor step + val verificationCode: String = "", + + val onVerificationCodeChange: (String) -> Unit = {}, + + val onVerifyClick: () -> Unit = {}, + + val selectedFactor: MfaFactor? = null, + + val resendTimer: Int = 0, + + val onResendCodeClick: (() -> Unit)? = null, + + // ShowRecoveryCodes step + val recoveryCodes: List? = null, + + val onCodesSavedClick: () -> Unit = {} +) { + /** + * Returns true if the current state is valid for the current step. + * + * This can be used to enable/disable action buttons in the UI. + */ + val isValid: Boolean + get() = when (step) { + MfaEnrollmentStep.SelectFactor -> availableFactors.isNotEmpty() + MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank() + MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null + MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6 + MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty() + } + + /** + * Returns true if there is an error in the current state. + */ + val hasError: Boolean + get() = !error.isNullOrBlank() + + /** + * Returns true if the skip action is available (only for SelectFactor step when not required). + */ + val canSkip: Boolean + get() = step == MfaEnrollmentStep.SelectFactor && onSkipClick != null + + /** + * Returns true if the back action is available (for all steps except SelectFactor). + */ + val canGoBack: Boolean + get() = step != MfaEnrollmentStep.SelectFactor +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt new file mode 100644 index 0000000000..8d64da6202 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +/** + * Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow. + * + * This enum defines the sequence of UI states that users progress through when enrolling + * in MFA, from selecting a factor to completing the setup with recovery codes. + * + * @since 10.0.0 + */ +enum class MfaEnrollmentStep { + /** + * The user is presented with a selection of available MFA factors to enroll in. + * The available factors are determined by the [com.firebase.ui.auth.configuration.MfaConfiguration]. + */ + SelectFactor, + + /** + * The user is configuring SMS-based MFA by entering their phone number. + * This step prepares to send an SMS verification code to the provided number. + */ + ConfigureSms, + + /** + * The user is configuring TOTP (Time-based One-Time Password) MFA. + * This step presents the TOTP secret (as both text and QR code) for the user + * to scan into their authenticator app. + */ + ConfigureTotp, + + /** + * The user is verifying their chosen MFA factor by entering a verification code. + * For SMS, this is the code received via text message. + * For TOTP, this is the code generated by their authenticator app. + */ + VerifyFactor, + + /** + * The enrollment is complete and recovery codes are displayed to the user. + * These backup codes can be used to sign in if the primary MFA method is unavailable. + * This step only appears if recovery codes are enabled in the configuration. + */ + ShowRecoveryCodes +} + +/** + * Returns the localized title text for this enrollment step. + * + * @param stringProvider The string provider for localized strings + * @return The localized title for this step + */ +fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = when (this) { + MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorTitle + MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle + MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle + MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle + MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle +} + +/** + * Returns localized helper text providing instructions for this step. + * + * @param stringProvider The string provider for localized strings + * @param selectedFactor The MFA factor being configured or verified. Used for [MfaEnrollmentStep.VerifyFactor] + * to provide factor-specific instructions. Ignored for other steps. + * @return Localized instructional text appropriate for this step + */ +fun MfaEnrollmentStep.getHelperText( + stringProvider: AuthUIStringProvider, + selectedFactor: MfaFactor? = null +): String = when (this) { + MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorHelper + MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsHelper + MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper + MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) { + MfaFactor.Sms -> stringProvider.mfaStepVerifyFactorSmsHelper + MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper + null -> stringProvider.mfaStepVerifyFactorGenericHelper + } + MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesHelper +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt new file mode 100644 index 0000000000..7776702ee6 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import java.io.IOException + +/** + * Maps Firebase Auth exceptions to localized error messages for MFA enrollment. + * + * @param stringProvider Provider for localized strings + * @return Localized error message appropriate for the exception type + */ +fun Exception.toMfaErrorMessage(stringProvider: AuthUIStringProvider): String { + return when (this) { + is FirebaseAuthRecentLoginRequiredException -> + stringProvider.mfaErrorRecentLoginRequired + is FirebaseAuthInvalidCredentialsException -> + stringProvider.mfaErrorInvalidVerificationCode + is IOException, is FirebaseNetworkException -> + stringProvider.mfaErrorNetwork + else -> stringProvider.mfaErrorGeneric + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt new file mode 100644 index 0000000000..8aec7c9c3c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt @@ -0,0 +1,376 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import android.app.Activity +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 +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.PhoneAuthCredential +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import kotlinx.coroutines.tasks.await + +/** + * Handler for SMS multi-factor authentication enrollment. + * + * This class manages the complete SMS enrollment flow, including: + * - Sending SMS verification codes to phone numbers + * - Resending codes with timer support + * - Verifying SMS codes entered by users + * - Finalizing enrollment with Firebase Authentication + * + * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure + * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. + * + * **Usage:** + * ```kotlin + * val handler = SmsEnrollmentHandler(auth, user) + * + * // Step 1: Send verification code + * val session = handler.sendVerificationCode("+1234567890") + * + * // Step 2: Display masked phone number and wait for user input + * val masked = session.getMaskedPhoneNumber() + * + * // Step 3: If needed, resend code after timer expires + * val newSession = handler.resendVerificationCode(session) + * + * // Step 4: Verify the code entered by the user + * val verificationCode = "123456" // From user input + * handler.enrollWithVerificationCode(session, verificationCode, "My Phone") + * ``` + * + * @property auth The [FirebaseAuth] instance + * @property user The [FirebaseUser] to enroll in SMS MFA + * + * @since 10.0.0 + * @see TotpEnrollmentHandler + * @see AuthProvider.Phone.verifyPhoneNumberAwait + */ +class SmsEnrollmentHandler( + private val activity: Activity, + private val auth: FirebaseAuth, + private val user: FirebaseUser +) { + private val phoneProvider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + smsCodeLength = SMS_CODE_LENGTH, + timeout = VERIFICATION_TIMEOUT_SECONDS, + isInstantVerificationEnabled = true + ) + /** + * Sends an SMS verification code to the specified phone number. + * + * This method initiates the SMS enrollment process by sending a verification code + * to the provided phone number. The code will be sent via SMS and should be + * displayed to the user for entry. + * + * **Important:** The user must re-authenticate before calling this method if their + * session is not recent. Use [FirebaseUser.reauthenticate] if needed. + * + * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890") + * @return An [SmsEnrollmentSession] containing the verification ID and metadata + * @throws Exception if the user needs to re-authenticate, phone number is invalid, + * or SMS sending fails + * + * @see resendVerificationCode + * @see SmsEnrollmentSession.getMaskedPhoneNumber + */ + suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession { + require(isValidPhoneNumber(phoneNumber)) { + "Phone number must be in E.164 format (e.g., +1234567890)" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = null + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = phoneNumber, + forceResendingToken = null, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Resends the SMS verification code to the phone number. + * + * This method uses the force resending token from the original session to + * explicitly request a new SMS code. This should only be called after the + * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits. + * + * @param session The original [SmsEnrollmentSession] from [sendVerificationCode] + * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp + * @throws Exception if resending fails or if the session doesn't have a resend token + * + * @see sendVerificationCode + */ + suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession { + require(session.forceResendingToken != null) { + "Cannot resend code without a force resending token" + } + + val multiFactorSession = user.multiFactor.session.await() + val result = phoneProvider.verifyPhoneNumberAwait( + auth = auth, + activity = activity, + phoneNumber = session.phoneNumber, + multiFactorSession = multiFactorSession, + forceResendingToken = session.forceResendingToken + ) + + return when (result) { + is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { + SmsEnrollmentSession( + verificationId = "", // Not needed when auto-verified + phoneNumber = session.phoneNumber, + forceResendingToken = session.forceResendingToken, + sentAt = System.currentTimeMillis(), + autoVerifiedCredential = result.credential + ) + } + is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { + SmsEnrollmentSession( + verificationId = result.verificationId, + phoneNumber = session.phoneNumber, + forceResendingToken = result.token, + sentAt = System.currentTimeMillis() + ) + } + } + } + + /** + * Verifies an SMS code and completes the enrollment process. + * + * This method creates a multi-factor assertion using the provided session and + * verification code, then enrolls the user in SMS MFA with Firebase Authentication. + * + * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode] + * @param verificationCode The 6-digit code from the SMS message + * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone") + * @throws Exception if the verification code is invalid or if enrollment fails + * + * @see sendVerificationCode + * @see resendVerificationCode + */ + suspend fun enrollWithVerificationCode( + session: SmsEnrollmentSession, + verificationCode: String, + displayName: String? = null + ) { + require(isValidCodeFormat(verificationCode)) { + "Verification code must be 6 digits" + } + + val credential = session.autoVerifiedCredential + ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode) + + val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential) + user.multiFactor.enroll(multiFactorAssertion, displayName).await() + } + + /** + * Validates that a verification code has the correct format for SMS. + * + * This method performs basic client-side validation to ensure the code: + * - Is not null or empty + * - Contains only digits + * - Has exactly 6 digits (the standard SMS code length) + * + * **Note:** This does not verify the code against the server. Use + * [enrollWithVerificationCode] to perform actual verification with Firebase. + * + * @param code The verification code to validate + * @return `true` if the code has a valid format, `false` otherwise + */ + fun isValidCodeFormat(code: String): Boolean { + return code.isNotBlank() && + code.length == SMS_CODE_LENGTH && + code.all { it.isDigit() } + } + + /** + * Validates that a phone number is in the correct E.164 format. + * + * E.164 format requirements: + * - Starts with "+" + * - Followed by 1-15 digits + * - No spaces, hyphens, or other characters + * - Minimum 4 digits total (country code + subscriber number) + * + * Examples of valid numbers: + * - +1234567890 (US) + * - +447911123456 (UK) + * - +33612345678 (France) + * + * @param phoneNumber The phone number to validate + * @return `true` if the phone number is in E.164 format, `false` otherwise + */ + fun isValidPhoneNumber(phoneNumber: String): Boolean { + return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$")) + } + + companion object { + /** + * The standard length for SMS verification codes. + */ + const val SMS_CODE_LENGTH = 6 + + /** + * The verification timeout in seconds for phone authentication. + * This is how long Firebase will wait for auto-verification before + * falling back to manual code entry. + */ + const val VERIFICATION_TIMEOUT_SECONDS = 60L + + /** + * The recommended delay in seconds before allowing code resend. + * This prevents users from spamming the resend functionality and + * respects carrier rate limits. + */ + const val RESEND_DELAY_SECONDS = 30 + + /** + * The Firebase factor ID for SMS multi-factor authentication. + */ + const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID + } +} + +/** + * Represents an active SMS enrollment session with verification state. + * + * This class holds all the information needed to complete an SMS enrollment, + * including the verification ID, phone number, and resend token. + * + * @property verificationId The verification ID from Firebase + * @property phoneNumber The phone number being verified in E.164 format + * @property forceResendingToken Optional token for resending the SMS code + * @property sentAt Timestamp in milliseconds when the code was sent + * @property autoVerifiedCredential Optional credential if auto-verification succeeded + * + * @since 10.0.0 + */ +data class SmsEnrollmentSession( + val verificationId: String, + val phoneNumber: String, + val forceResendingToken: PhoneAuthProvider.ForceResendingToken?, + val sentAt: Long, + val autoVerifiedCredential: PhoneAuthCredential? = null +) { + /** + * Returns a masked version of the phone number for display purposes. + * + * Masks the middle digits of the phone number while keeping the country code + * and last few digits visible for user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" + * - "+447911123456" → "+44•••••••456" + * + * @return The masked phone number string + */ + fun getMaskedPhoneNumber(): String { + return maskPhoneNumber(phoneNumber) + } + + /** + * Checks if the resend delay has elapsed since the code was sent. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return `true` if enough time has passed to allow resending + */ + fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return elapsed >= delaySec + } + + /** + * Returns the remaining seconds until resend is allowed. + * + * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) + * @return The number of seconds remaining, or 0 if resend is already allowed + */ + fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int { + val elapsed = (System.currentTimeMillis() - sentAt) / 1000 + return maxOf(0, delaySec - elapsed.toInt()) + } +} + +/** + * Masks the middle digits of a phone number for privacy. + * + * The function keeps the country code (first 1-3 characters after +) and + * the last 2-4 digits visible, masking everything in between with bullets. + * Longer phone numbers show more last digits for better user confirmation. + * + * Examples: + * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits) + * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits) + * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits) + * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits) + * + * @param phoneNumber The phone number to mask in E.164 format + * @return The masked phone number string + */ +fun maskPhoneNumber(phoneNumber: String): String { + if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { + return phoneNumber + } + + // Determine country code length (typically 1-3 digits after +) + val digitsOnly = phoneNumber.substring(1) // Remove + + val countryCodeLength = when { + digitsOnly.length > 10 -> 2 // Likely 2-digit country code + digitsOnly[0] == '1' -> 1 // North America + else -> 2 // Most other countries + } + + val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + + // Keep last 3-4 digits visible, with longer numbers showing more + 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 + } + val lastDigits = phoneNumber.takeLast(lastDigitsCount) + val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount + + return "$countryCode${"•".repeat(maskedLength)}$lastDigits" +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt new file mode 100644 index 0000000000..33bc874c62 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorAssertion +import com.google.firebase.auth.TotpMultiFactorGenerator +import kotlinx.coroutines.tasks.await + +/** + * Handler for TOTP (Time-based One-Time Password) multi-factor authentication enrollment. + * + * This class manages the complete TOTP enrollment flow, including: + * - Generating TOTP secrets + * - Creating QR codes for authenticator apps + * - Verifying TOTP codes with clock drift tolerance + * - Finalizing enrollment with Firebase Authentication + * + * **Usage:** + * ```kotlin + * val handler = TotpEnrollmentHandler(auth, user) + * + * // Step 1: Generate a TOTP secret + * val totpSecret = handler.generateSecret() + * + * // Step 2: Display QR code to user + * val qrCodeUrl = totpSecret.generateQrCodeUrl(user.email, "My App") + * + * // Step 3: Verify the code entered by the user + * val verificationCode = "123456" // From user input + * handler.enrollWithVerificationCode(totpSecret, verificationCode, "My Authenticator") + * ``` + * + * @property auth The [FirebaseAuth] instance + * @property user The [FirebaseUser] to enroll in TOTP MFA + * + * @since 10.0.0 + */ +class TotpEnrollmentHandler( + private val auth: FirebaseAuth, + private val user: FirebaseUser +) { + /** + * Generates a new TOTP secret for the current user. + * + * This method initiates the TOTP enrollment process by creating a new secret that + * can be shared with an authenticator app. The secret must be displayed to the user + * (either as text or a QR code) so they can add it to their authenticator app. + * + * **Important:** The user must re-authenticate before calling this method if their + * session is not recent. Use [FirebaseUser.reauthenticate] if needed. + * + * @return A [TotpSecret] containing the shared secret and configuration parameters + * @throws Exception if the user needs to re-authenticate or if secret generation fails + * + * @see TotpSecret.generateQrCodeUrl + * @see TotpSecret.openInOtpApp + */ + suspend fun generateSecret(): TotpSecret { + // Get the multi-factor session + val multiFactorSession = user.multiFactor.session.await() + + // Generate the TOTP secret + val firebaseTotpSecret = TotpMultiFactorGenerator.generateSecret(multiFactorSession).await() + + return TotpSecret.from(firebaseTotpSecret) + } + + /** + * Verifies a TOTP code and completes the enrollment process. + * + * This method creates a multi-factor assertion using the provided TOTP secret and + * verification code, then enrolls the user in TOTP MFA with Firebase Authentication. + * + * The verification includes clock drift tolerance as configured in your Firebase project, + * allowing codes from adjacent time windows to be accepted. This accommodates minor + * time synchronization differences between the server and the user's device. + * + * @param totpSecret The [TotpSecret] generated in the first step + * @param verificationCode The 6-digit code from the user's authenticator app + * @param displayName Optional friendly name for this MFA factor (e.g., "Google Authenticator") + * @throws Exception if the verification code is invalid or if enrollment fails + * + * @see generateSecret + */ + suspend fun enrollWithVerificationCode( + totpSecret: TotpSecret, + verificationCode: String, + displayName: String? = null + ) { + // Create the multi-factor assertion for enrollment + val multiFactorAssertion: MultiFactorAssertion = + TotpMultiFactorGenerator.getAssertionForEnrollment( + totpSecret.getFirebaseTotpSecret(), + verificationCode + ) + + // Enroll the user with the TOTP factor + user.multiFactor.enroll(multiFactorAssertion, displayName).await() + } + + /** + * Validates that a verification code has the correct format for TOTP. + * + * This method performs basic client-side validation to ensure the code: + * - Is not null or empty + * - Contains only digits + * - Has exactly 6 digits (the standard TOTP code length) + * + * **Note:** This does not verify the code against the TOTP secret. Use + * [enrollWithVerificationCode] to perform actual verification with Firebase. + * + * @param code The verification code to validate + * @return `true` if the code has a valid format, `false` otherwise + */ + fun isValidCodeFormat(code: String): Boolean { + return code.isNotBlank() && + code.length == 6 && + code.all { it.isDigit() } + } + + companion object { + /** + * The standard length for TOTP verification codes. + */ + const val TOTP_CODE_LENGTH = 6 + + /** + * The standard time interval in seconds for TOTP codes. + */ + const val TOTP_TIME_INTERVAL_SECONDS = 30 + + /** + * The Firebase factor ID for TOTP multi-factor authentication. + */ + const val FACTOR_ID = TotpMultiFactorGenerator.FACTOR_ID + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt new file mode 100644 index 0000000000..7a4121ba8b --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.mfa + +import android.content.Intent +import android.net.Uri +import com.google.firebase.auth.TotpSecret as FirebaseTotpSecret + +/** + * Wrapper class for Firebase TOTP secret that provides additional utility methods + * for enrollment and integration with authenticator apps. + * + * This class encapsulates the Firebase [FirebaseTotpSecret] and provides methods to: + * - Access the shared secret key + * - Generate QR code URLs for easy scanning + * - Open authenticator apps for automatic configuration + * - Generate hashing algorithm and code generation parameters + * + * @property firebaseTotpSecret The underlying Firebase TOTP secret + * + * @since 10.0.0 + */ +class TotpSecret internal constructor( + private val firebaseTotpSecret: FirebaseTotpSecret +) { + /** + * The shared secret key that should be entered into an authenticator app. + * This is a base32-encoded string that can be manually typed if QR scanning is not available. + */ + val sharedSecretKey: String + get() = firebaseTotpSecret.sharedSecretKey + + /** + * Generates a Google Authenticator-compatible URI that can be encoded as a QR code + * or used to automatically configure an authenticator app. + * + * The generated URI follows the format: + * `otpauth://totp/{accountName}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}` + * + * @param accountName The account identifier, typically the user's email address + * @param issuer The name of your application or service + * @return A URI string that can be converted to a QR code or used as a deep link + * + * @see openInOtpApp + */ + fun generateQrCodeUrl(accountName: String, issuer: String): String { + return firebaseTotpSecret.generateQrCodeUrl(accountName, issuer) + } + + /** + * Attempts to open the device's default authenticator app with the TOTP configuration. + * + * This method creates an Intent with the provided QR code URL and attempts to open + * an authenticator app (such as Google Authenticator) that can handle the + * `otpauth://` URI scheme. If successful, the app will be pre-configured with the + * TOTP secret without requiring the user to manually scan a QR code. + * + * **Note:** This method may fail silently if no compatible authenticator app is installed + * or if the app doesn't support automatic configuration via URI. + * + * @param qrCodeUrl The OTP auth URL generated by [generateQrCodeUrl] + * + * @see generateQrCodeUrl + */ + fun openInOtpApp(qrCodeUrl: String) { + firebaseTotpSecret.openInOtpApp(qrCodeUrl) + } + + /** + * Gets the underlying Firebase TOTP secret for use in enrollment operations. + * + * This method is primarily used internally by the enrollment handler to complete + * the TOTP enrollment with Firebase Authentication. + * + * @return The underlying [FirebaseTotpSecret] instance + */ + internal fun getFirebaseTotpSecret(): FirebaseTotpSecret { + return firebaseTotpSecret + } + + companion object { + /** + * Creates a [TotpSecret] instance from a Firebase TOTP secret. + * + * @param firebaseTotpSecret The Firebase TOTP secret to wrap + * @return A new [TotpSecret] instance + */ + internal fun from(firebaseTotpSecret: FirebaseTotpSecret): TotpSecret { + return TotpSecret(firebaseTotpSecret) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt new file mode 100644 index 0000000000..9a3f5e1a27 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme +import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults + +/** + * A customizable button for an authentication provider. + * + * This button displays the icon and name of an authentication provider (e.g., Google, Facebook). + * It is designed to be used within a list of sign-in options. The button's appearance can be + * customized using the [style] parameter, and its text is localized via the [stringProvider]. + * + * **Example usage:** + * ```kotlin + * AuthProviderButton( + * provider = AuthProvider.Facebook(), + * onClick = { /* Handle Facebook sign-in */ }, + * stringProvider = DefaultAuthUIStringProvider(LocalContext.current) + * ) + * ``` + * + * @param modifier A modifier for the button + * @param provider The provider to represent. + * @param onClick A callback when the button is clicked + * @param enabled If the button is enabled. Defaults to true. + * @param style Optional custom styling for the button. + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @param subtitle Optional subtitle text to display below the provider label (e.g., user email) + * @param label Optional custom label to override the default provider label + * + * @since 10.0.0 + */ +@Composable +fun AuthProviderButton( + modifier: Modifier = Modifier, + provider: AuthProvider, + onClick: () -> Unit, + enabled: Boolean = true, + style: AuthUITheme.ProviderStyle? = null, + stringProvider: AuthUIStringProvider, + subtitle: String? = null, + label: String? = null, + showAsContinue: Boolean = false, +) { + val context = LocalContext.current + val authTheme = LocalAuthUITheme.current + val providerLabel = + label ?: resolveProviderLabel(provider, stringProvider, context, showAsContinue) + val providerStyle = resolveProviderStyle( + provider = provider, + style = style, + providerStyles = authTheme.providerStyles, + defaultButtonShape = authTheme.providerButtonShape + ) + + Button( + modifier = modifier, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = if (subtitle != null) 12.dp else 8.dp + ), + colors = ButtonDefaults.buttonColors( + containerColor = providerStyle.backgroundColor, + contentColor = providerStyle.contentColor, + ), + shape = providerStyle.shape ?: RoundedCornerShape(4.dp), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = providerStyle.elevation + ), + onClick = onClick, + enabled = enabled, + ) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val providerIcon = providerStyle.icon + if (providerIcon != null) { + val iconTint = providerStyle.iconTint + if (iconTint != null) { + Icon( + modifier = Modifier + .size(24.dp), + painter = providerIcon.painter, + contentDescription = providerLabel, + tint = iconTint + ) + } else { + Image( + modifier = Modifier + .size(24.dp), + painter = providerIcon.painter, + contentDescription = providerLabel + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } + + if (subtitle != null) { + Column( + verticalArrangement = Arrangement.Center + ) { + Text( + text = providerLabel, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = subtitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodySmall, + ) + } + } else { + Text( + text = providerLabel, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } +} + +internal fun resolveProviderStyle( + provider: AuthProvider, + style: AuthUITheme.ProviderStyle?, + providerStyles: Map, + defaultButtonShape: Shape?, +): AuthUITheme.ProviderStyle { + // If explicit style is provided, use it but apply default shape if needed + if (style != null) { + return if (style.shape == null) { + style.copy(shape = defaultButtonShape ?: RoundedCornerShape(4.dp)) + } else { + style + } + } + + // Get the configured style from the theme or fall back to defaults + val configuredStyle = providerStyles[provider.providerId] + ?: ProviderStyleDefaults.default[provider.providerId] + ?: AuthUITheme.ProviderStyle.Empty + + // Handle GenericOAuth providers with custom properties + val resolvedStyle = if (provider is AuthProvider.GenericOAuth) { + configuredStyle.copy( + icon = provider.buttonIcon ?: configuredStyle.icon, + backgroundColor = provider.buttonColor ?: configuredStyle.backgroundColor, + contentColor = provider.contentColor ?: configuredStyle.contentColor, + ) + } else { + configuredStyle + } + + // Apply default button shape if no shape is explicitly set + return if (resolvedStyle.shape == null) { + resolvedStyle.copy(shape = defaultButtonShape ?: RoundedCornerShape(4.dp)) + } else { + resolvedStyle + } +} + +internal fun resolveProviderLabel( + provider: AuthProvider, + stringProvider: AuthUIStringProvider, + context: Context, + showAsContinue: Boolean = false, +): String = when (provider) { + is AuthProvider.GenericOAuth -> provider.buttonLabel + is AuthProvider.Apple -> { + // Use Apple-specific locale if provided, otherwise use default stringProvider + if (provider.locale != null) { + val appleLocale = java.util.Locale.forLanguageTag(provider.locale) + val appleStringProvider = DefaultAuthUIStringProvider(context, appleLocale) + if (showAsContinue) appleStringProvider.continueWithApple else appleStringProvider.signInWithApple + } else { + if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple + } + } + + else -> when (Provider.fromId(provider.providerId)) { + Provider.GOOGLE -> if (showAsContinue) stringProvider.continueWithGoogle else stringProvider.signInWithGoogle + Provider.FACEBOOK -> if (showAsContinue) stringProvider.continueWithFacebook else stringProvider.signInWithFacebook + Provider.TWITTER -> if (showAsContinue) stringProvider.continueWithTwitter else stringProvider.signInWithTwitter + Provider.GITHUB -> if (showAsContinue) stringProvider.continueWithGithub else stringProvider.signInWithGithub + Provider.EMAIL -> if (showAsContinue) stringProvider.continueWithEmail else stringProvider.signInWithEmail + Provider.PHONE -> if (showAsContinue) stringProvider.continueWithPhone else stringProvider.signInWithPhone + Provider.ANONYMOUS -> stringProvider.signInAnonymously + Provider.MICROSOFT -> if (showAsContinue) stringProvider.continueWithMicrosoft else stringProvider.signInWithMicrosoft + Provider.YAHOO -> if (showAsContinue) stringProvider.continueWithYahoo else stringProvider.signInWithYahoo + Provider.APPLE -> if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple + null -> "Unknown Provider" + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAuthProviderButton() { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AuthProviderButton( + provider = AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Google( + scopes = emptyList(), + serverClientId = null + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Facebook(), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Twitter( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Github( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Microsoft( + tenant = null, + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Yahoo( + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Apple( + locale = null, + customParameters = emptyMap() + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.Anonymous, + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Generic Provider", + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Gray, + contentColor = Color.White + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + providerId = "google.com", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Custom Style", + buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), + buttonColor = Color.Gray, + contentColor = Color.White + ), + onClick = {}, + style = AuthUITheme.ProviderStyle( + icon = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]?.icon, + backgroundColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.backgroundColor, + contentColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.contentColor, + iconTint = Color.Red, + shape = RoundedCornerShape(24.dp), + elevation = 6.dp + ), + stringProvider = DefaultAuthUIStringProvider(context) + ) + AuthProviderButton( + provider = AuthProvider.GenericOAuth( + providerName = "Generic Provider", + providerId = "unknown_provider", + scopes = emptyList(), + customParameters = emptyMap(), + buttonLabel = "Unsupported Provider", + buttonIcon = null, + buttonColor = null, + contentColor = null, + ), + onClick = {}, + stringProvider = DefaultAuthUIStringProvider(context) + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt new file mode 100644 index 0000000000..253a6e260a --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.PasswordRule +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.configuration.validators.FieldValidator +import com.firebase.ui.auth.configuration.validators.PasswordValidator + +/** + * A customizable input field with built-in validation display. + * + * **Example usage:** + * ```kotlin + * val emailTextValue = remember { mutableStateOf("") } + * + * val emailValidator = remember { + * EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + * } + * + * AuthTextField( + * value = emailTextValue, + * onValueChange = { emailTextValue.value = it }, + * label = { + * Text("Email") + * }, + * validator = emailValidator + * ) + * ``` + * + * @param modifier A modifier for the field. + * @param value The current value of the text field. + * @param onValueChange A callback when the value changes. + * @param label The label for the text field. + * @param enabled If the field is enabled. + * @param isError Manually set the error state. + * @param errorMessage A custom error message to display. + * @param validator A validator to automatically handle error state and messages. + * @param keyboardOptions Keyboard options for the field. + * @param keyboardActions Keyboard actions for the field. + * @param visualTransformation Visual transformation for the input (e.g., password). + * @param leadingIcon An optional icon to display at the start of the field. + * @param trailingIcon An optional icon to display at the start of the field. + */ +@Composable +fun AuthTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + label: @Composable (() -> Unit)? = null, + isSecureTextField: Boolean = false, + enabled: Boolean = true, + isError: Boolean? = null, + errorMessage: String? = null, + validator: FieldValidator? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + var passwordVisible by remember { mutableStateOf(false) } + + // Automatically set the correct keyboard type based on validator or field type + val resolvedKeyboardOptions = remember(validator, isSecureTextField, keyboardOptions) { + when { + keyboardOptions != KeyboardOptions.Default -> keyboardOptions + validator is EmailValidator -> KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) + isSecureTextField -> KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ) + else -> keyboardOptions + } + } + + TextField( + modifier = modifier + .fillMaxWidth(), + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + validator?.validate(newValue) + }, + label = label, + singleLine = true, + enabled = enabled, + isError = isError ?: validator?.hasError ?: false, + supportingText = { + if (validator?.hasError ?: false) { + Text(text = errorMessage ?: validator.errorMessage) + } + }, + keyboardOptions = resolvedKeyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = if (isSecureTextField && !passwordVisible) + PasswordVisualTransformation() else visualTransformation, + leadingIcon = leadingIcon ?: when { + validator is EmailValidator -> { + { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "Email Input Icon" + ) + } + } + + isSecureTextField -> { + { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Password Input Icon" + ) + } + } + + else -> null + }, + trailingIcon = trailingIcon ?: { + if (isSecureTextField) { + IconButton( + onClick = { + passwordVisible = !passwordVisible + } + ) { + Icon( + imageVector = if (passwordVisible) + Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PreviewAuthTextField() { + val context = LocalContext.current + val nameTextValue = remember { mutableStateOf("") } + val emailTextValue = remember { mutableStateOf("") } + val passwordTextValue = remember { mutableStateOf("") } + val emailValidator = remember { + EmailValidator(stringProvider = DefaultAuthUIStringProvider(context)) + } + val passwordValidator = remember { + PasswordValidator( + stringProvider = DefaultAuthUIStringProvider(context), + rules = listOf( + PasswordRule.MinimumLength(8), + PasswordRule.RequireUppercase, + PasswordRule.RequireLowercase, + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AuthTextField( + value = nameTextValue.value, + label = { + Text("Name") + }, + onValueChange = { text -> + nameTextValue.value = text + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = emailTextValue.value, + validator = emailValidator, + label = { + Text("Email") + }, + onValueChange = { text -> + emailTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Email, + contentDescription = "" + ) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = passwordTextValue.value, + validator = passwordValidator, + isSecureTextField = true, + label = { + Text("Password") + }, + onValueChange = { text -> + passwordTextValue.value = text + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "" + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt new file mode 100644 index 0000000000..425aa32bc6 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.data.ALL_COUNTRIES +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.util.CountryUtils +import kotlinx.coroutines.launch + +/** + * A country selector component that displays the selected country's flag and dial code with a dropdown icon. + * Designed to be used as a leadingIcon in a TextField. + * + * @param selectedCountry The currently selected country. + * @param onCountrySelected Callback when a country is selected. + * @param enabled Whether the selector is enabled. + * @param allowedCountries Optional set of allowed country codes to filter the list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CountrySelector( + selectedCountry: CountryData, + onCountrySelected: (CountryData) -> Unit, + enabled: Boolean = true, + allowedCountries: Set? = null, +) { + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + var showBottomSheet by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + val countriesList = remember(allowedCountries) { + if (allowedCountries != null) { + CountryUtils.filterByAllowedCountries(allowedCountries) + } else { + ALL_COUNTRIES + } + } + + val filteredCountries = remember(searchQuery, countriesList) { + if (searchQuery.isEmpty()) { + countriesList + } else { + CountryUtils.search(searchQuery).filter { country -> + countriesList.any { it.countryCode == country.countryCode } + } + } + } + + // Clickable row showing flag, dial code and dropdown icon + Row( + modifier = Modifier + .fillMaxHeight() + .clickable(enabled = enabled) { + showBottomSheet = true + } + .padding(start = 8.dp) + .semantics { + role = Role.DropdownList + contentDescription = "Country selector" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = selectedCountry.flagEmoji, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = selectedCountry.dialCode, + style = MaterialTheme.typography.bodyLarge, + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select country", + modifier = Modifier.padding(PaddingValues.Zero) + ) + } + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + searchQuery = "" + }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Text( + text = stringProvider.countrySelectorModalTitle, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringProvider.searchCountriesHint) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + .testTag("CountrySelector LazyColumn") + ) { + items(filteredCountries) { country -> + Button( + onClick = { + onCountrySelected(country) + scope.launch { + sheetState.hide() + showBottomSheet = false + searchQuery = "" + } + }, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + containerColor = Color.Transparent + ), + contentPadding = PaddingValues.Zero + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = country.flagEmoji, + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = country.name, + style = MaterialTheme.typography.bodyLarge + ) + } + Text( + text = country.dialCode, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt new file mode 100644 index 0000000000..dff4daa60c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.DialogProperties +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +/** + * A composable dialog for displaying authentication errors with recovery options. + * + * This dialog provides friendly error messages and actionable recovery suggestions + * based on the specific [AuthException] type. It integrates with [AuthUIStringProvider] + * for localization support. + * + * **Example usage:** + * ```kotlin + * var showError by remember { mutableStateOf(null) } + * + * if (showError != null) { + * ErrorRecoveryDialog( + * error = showError!!, + * stringProvider = stringProvider, + * onRetry = { + * showError = null + * // Retry authentication operation + * }, + * onDismiss = { + * showError = null + * } + * ) + * } + * ``` + * + * @param error The [AuthException] to display recovery information for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @param onRetry Callback invoked when the user taps the retry action + * @param onDismiss Callback invoked when the user dismisses the dialog + * @param modifier Optional [Modifier] for the dialog + * @param onRecover Optional callback for custom recovery actions based on the exception type + * @param properties Optional [DialogProperties] for dialog configuration + * + * @since 10.0.0 + */ +@Composable +fun ErrorRecoveryDialog( + error: AuthException, + stringProvider: AuthUIStringProvider, + onRetry: (AuthException) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onRecover: ((AuthException) -> Unit)? = null, + properties: DialogProperties = DialogProperties() +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringProvider.errorDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = getRecoveryMessage(error, stringProvider), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + if (isRecoverable(error)) { + TextButton( + onClick = { + onRecover?.invoke(error) ?: onRetry(error) + } + ) { + Text( + text = getRecoveryActionText(error, stringProvider), + style = MaterialTheme.typography.labelLarge + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringProvider.dismissAction, + style = MaterialTheme.typography.labelLarge + ) + } + }, + modifier = modifier, + properties = properties + ) +} + +/** + * Gets the appropriate recovery message for the given [AuthException]. + * + * @param error The [AuthException] to get the message for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @return The localized recovery message + */ +private fun getRecoveryMessage( + error: AuthException, + stringProvider: AuthUIStringProvider +): String { + return when (error) { + is AuthException.NetworkException -> stringProvider.networkErrorRecoveryMessage + is AuthException.InvalidCredentialsException -> { + // Use the actual error message from Firebase if available, otherwise fallback to generic message + error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" } + ?: stringProvider.invalidCredentialsRecoveryMessage + } + is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage + is AuthException.WeakPasswordException -> { + // Include specific reason if available + val baseMessage = stringProvider.weakPasswordRecoveryMessage + error.reason?.let { reason -> + "$baseMessage\n\nReason: $reason" + } ?: baseMessage + } + + is AuthException.EmailAlreadyInUseException -> { + // Include email if available + val baseMessage = stringProvider.emailAlreadyInUseRecoveryMessage + error.email?.let { email -> + "$baseMessage ($email)" + } ?: baseMessage + } + + is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage + is AuthException.PhoneVerificationCooldownException -> { + // Use the custom message which includes remaining cooldown time + error.message ?: stringProvider.unknownErrorRecoveryMessage + } + is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage + is AuthException.AccountLinkingRequiredException -> { + // Use the custom message which includes email and provider details + error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage + } + is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage + is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage + is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage + is AuthException.EmailLinkDifferentAnonymousUserException -> + stringProvider.emailLinkDifferentAnonymousUserMessage + is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage + is AuthException.EmailLinkCrossDeviceLinkingException -> { + val providerName = error.providerName ?: stringProvider.emailProvider + stringProvider.emailLinkCrossDeviceLinkingMessage(providerName) + } + is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage + is AuthException.UnknownException -> { + // Use custom message if available (e.g., for configuration errors) + error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage + } + else -> stringProvider.unknownErrorRecoveryMessage + } +} + +/** + * Gets the appropriate recovery action text for the given [AuthException]. + * + * @param error The [AuthException] to get the action text for + * @param stringProvider The [AuthUIStringProvider] for localized strings + * @return The localized action text + */ +private fun getRecoveryActionText( + error: AuthException, + stringProvider: AuthUIStringProvider +): String { + return when (error) { + is AuthException.AuthCancelledException -> error.message ?: stringProvider.continueText + is AuthException.EmailAlreadyInUseException -> stringProvider.signInDefault // Use existing "Sign in" text + is AuthException.AccountLinkingRequiredException -> stringProvider.signInDefault // User needs to sign in to link accounts + is AuthException.MfaRequiredException -> stringProvider.continueText // Use "Continue" for MFA + is AuthException.EmailLinkPromptForEmailException -> stringProvider.continueText + is AuthException.EmailLinkCrossDeviceLinkingException -> stringProvider.continueText + is AuthException.EmailLinkWrongDeviceException -> stringProvider.continueText + is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.dismissAction + is AuthException.UserNotFoundException -> stringProvider.signupPageTitle // Navigate to sign-up when user not found + is AuthException.NetworkException, + is AuthException.InvalidCredentialsException, + is AuthException.WeakPasswordException, + is AuthException.TooManyRequestsException, + is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction + is AuthException.UnknownException -> stringProvider.retryAction + + else -> stringProvider.retryAction + } +} + +/** + * Determines if the given [AuthException] is recoverable through user action. + * + * @param error The [AuthException] to check + * @return `true` if the error is recoverable, `false` otherwise + */ +private fun isRecoverable(error: AuthException): Boolean { + return when (error) { + is AuthException.NetworkException -> true + is AuthException.InvalidCredentialsException -> true + is AuthException.UserNotFoundException -> true + is AuthException.WeakPasswordException -> true + is AuthException.EmailAlreadyInUseException -> true + is AuthException.TooManyRequestsException -> false // User must wait + is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown + is AuthException.MfaRequiredException -> true + is AuthException.AccountLinkingRequiredException -> true + is AuthException.AuthCancelledException -> true + is AuthException.EmailLinkPromptForEmailException -> true + is AuthException.EmailLinkCrossDeviceLinkingException -> true + is AuthException.EmailLinkWrongDeviceException -> true + is AuthException.EmailLinkDifferentAnonymousUserException -> false + is AuthException.UnknownException -> true + else -> true + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt new file mode 100644 index 0000000000..754aa5cc78 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Renders a QR code from the provided content string. + * + * This component is typically used to display TOTP enrollment URIs. The QR code is generated on the + * fly and memoized for the given [content]. + * + * @param content The string content to encode into the QR code (for example the TOTP URI). + * @param modifier Optional [Modifier] applied to the QR container. + * @param size The size of the QR code square in density-independent pixels. + * @param foregroundColor Color used to render the QR pixels (defaults to black). + * @param backgroundColor Background color for the QR code (defaults to white). + */ +@Composable +fun QrCodeImage( + content: String, + modifier: Modifier = Modifier, + size: Dp = 250.dp, + foregroundColor: Color = Color.Black, + backgroundColor: Color = Color.White +) { + val bitmap = remember(content, size, foregroundColor, backgroundColor) { + generateQrCodeBitmap( + content = content, + sizePx = (size.value * 2).toInt(), // Render at 2x for better scaling quality. + foregroundColor = foregroundColor, + backgroundColor = backgroundColor + ) + } + + Box( + modifier = modifier + .size(size) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "QR code for authenticator app setup", + modifier = Modifier.size(size) + ) + } + } +} + +private fun generateQrCodeBitmap( + content: String, + sizePx: Int, + foregroundColor: Color, + backgroundColor: Color +): Bitmap? { + return try { + val qrCodeWriter = QRCodeWriter() + val hints = mapOf( + EncodeHintType.MARGIN to 1 // Small margin keeps QR code compact while remaining scannable. + ) + + val bitMatrix = qrCodeWriter.encode( + content, + BarcodeFormat.QR_CODE, + sizePx, + sizePx, + hints + ) + + val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + + val foregroundArgb = android.graphics.Color.argb( + (foregroundColor.alpha * 255).toInt(), + (foregroundColor.red * 255).toInt(), + (foregroundColor.green * 255).toInt(), + (foregroundColor.blue * 255).toInt() + ) + + val backgroundArgb = android.graphics.Color.argb( + (backgroundColor.alpha * 255).toInt(), + (backgroundColor.red * 255).toInt(), + (backgroundColor.green * 255).toInt(), + (backgroundColor.blue * 255).toInt() + ) + + for (x in 0 until sizePx) { + for (y in 0 until sizePx) { + bitmap.setPixel( + x, + y, + if (bitMatrix[x, y]) foregroundArgb else backgroundArgb + ) + } + } + + bitmap + } catch (e: WriterException) { + null + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt new file mode 100644 index 0000000000..08c9ed89cc --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.google.firebase.auth.EmailAuthProvider +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Dialog presented when Firebase requires the current user to re-authenticate before performing + * a sensitive operation (for example, MFA enrollment). + */ +@Composable +fun ReauthenticationDialog( + user: FirebaseUser, + onDismiss: () -> Unit, + onSuccess: () -> Unit, + onError: (Exception) -> Unit +) { + var password by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + val stringProvider = LocalAuthUIStringProvider.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { + Text( + text = stringProvider.reauthDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringProvider.reauthDialogMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + user.email?.let { email -> + Text( + text = stringProvider.reauthAccountLabel(email), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text(stringProvider.passwordHint) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (password.isNotBlank() && !isLoading) { + coroutineScope.launch { + reauthenticate( + user = user, + password = password, + onLoading = { isLoading = it }, + onSuccess = onSuccess, + onError = { error -> + errorMessage = error.toUserMessage(stringProvider) + onError(error) + } + ) + } + } + } + ), + enabled = !isLoading, + isError = errorMessage != null, + supportingText = errorMessage?.let { message -> { Text(message) } }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp) + ) + } + } + }, + confirmButton = { + Button( + onClick = { + coroutineScope.launch { + reauthenticate( + user = user, + password = password, + onLoading = { isLoading = it }, + onSuccess = onSuccess, + onError = { error -> + errorMessage = error.toUserMessage(stringProvider) + onError(error) + } + ) + } + }, + enabled = password.isNotBlank() && !isLoading + ) { + Text(stringProvider.verifyAction) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isLoading + ) { + Text(stringProvider.dismissAction) + } + } + ) +} + +private suspend fun reauthenticate( + user: FirebaseUser, + password: String, + onLoading: (Boolean) -> Unit, + onSuccess: () -> Unit, + onError: (Exception) -> Unit +) { + try { + onLoading(true) + val email = requireNotNull(user.email) { + "Email must be available to re-authenticate with password." + } + + val credential = EmailAuthProvider.getCredential(email, password) + user.reauthenticate(credential).await() + onSuccess() + } catch (e: Exception) { + onError(e) + } finally { + onLoading(false) + } +} + +private fun Exception.toUserMessage(stringProvider: AuthUIStringProvider): String = when { + message?.contains("password", ignoreCase = true) == true -> + stringProvider.incorrectPasswordError + message?.contains("network", ignoreCase = true) == true -> + stringProvider.noInternet + else -> stringProvider.reauthGenericError +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt new file mode 100644 index 0000000000..5cf33cd5a9 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R + +@Composable +fun TermsAndPrivacyForm( + modifier: Modifier = Modifier, + tosUrl: String?, + ppUrl: String? +) { + val uriHandler = LocalUriHandler.current + Row( + modifier = modifier, + ) { + TextButton( + onClick = { + tosUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = stringResource(R.string.fui_terms_of_service), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.width(24.dp)) + TextButton( + onClick = { + ppUrl?.let { + uriHandler.openUri(it) + } + }, + contentPadding = PaddingValues.Zero, + ) { + Text( + text = stringResource(R.string.fui_privacy_policy), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt new file mode 100644 index 0000000000..626c56c04e --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider + +/** + * CompositionLocal for accessing the top-level dialog controller from any composable. + */ +val LocalTopLevelDialogController = compositionLocalOf { + null +} + +/** + * A top-level dialog controller that allows any child composable to show error recovery dialogs. + * + * It provides a single point of control for showing dialogs from anywhere in the composition tree, + * preventing duplicate dialogs when multiple screens observe the same error state. + * + * **Usage:** + * ```kotlin + * // At the root of your auth flow (FirebaseAuthScreen): + * val dialogController = rememberTopLevelDialogController(stringProvider) + * + * CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) { + * // Your auth screens... + * + * // Show dialog at root level (only one instance) + * dialogController.CurrentDialog() + * } + * + * // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.): + * val dialogController = LocalTopLevelDialogController.current + * + * LaunchedEffect(error) { + * error?.let { exception -> + * dialogController?.showErrorDialog( + * exception = exception, + * onRetry = { ... }, + * onRecover = { ... }, + * onDismiss = { ... } + * ) + * } + * } + * ``` + * + * @since 10.0.0 + */ +class TopLevelDialogController( + private val stringProvider: AuthUIStringProvider, + private val authState: AuthState +) { + private var dialogState by mutableStateOf(null) + private val shownErrorStates = mutableSetOf() + + /** + * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog]. + * Automatically prevents duplicate dialogs for the same AuthState.Error instance. + * + * @param exception The auth exception to display + * @param onRetry Callback when user clicks retry button + * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen) + * @param onDismiss Callback when dialog is dismissed + */ + fun showErrorDialog( + exception: AuthException, + onRetry: (AuthException) -> Unit = {}, + onRecover: (AuthException) -> Unit = {}, + onDismiss: () -> Unit = {} + ) { + // Get current error state + val currentErrorState = authState as? AuthState.Error + + // If this exact error state has already been shown, skip + if (currentErrorState != null && currentErrorState in shownErrorStates) { + return + } + + // Mark this error state as shown + currentErrorState?.let { shownErrorStates.add(it) } + + dialogState = DialogState.ErrorDialog( + exception = exception, + onRetry = onRetry, + onRecover = onRecover, + onDismiss = { + dialogState = null + onDismiss() + } + ) + } + + /** + * Dismisses the currently shown dialog. + */ + fun dismissDialog() { + dialogState = null + } + + /** + * Composable that renders the current dialog, if any. + * This should be called once at the root level of your auth flow. + * + * Uses the existing [ErrorRecoveryDialog] component. + */ + @Composable + fun CurrentDialog() { + val state = dialogState + when (state) { + is DialogState.ErrorDialog -> { + ErrorRecoveryDialog( + error = state.exception, + stringProvider = stringProvider, + onRetry = { exception -> + state.onRetry(exception) + state.onDismiss() + }, + onRecover = { exception -> + state.onRecover(exception) + state.onDismiss() + }, + onDismiss = state.onDismiss + ) + } + null -> { + // No dialog to show + } + } + } + + private sealed class DialogState { + data class ErrorDialog( + val exception: AuthException, + val onRetry: (AuthException) -> Unit, + val onRecover: (AuthException) -> Unit, + val onDismiss: () -> Unit + ) : DialogState() + } +} + +/** + * Creates and remembers a [TopLevelDialogController]. + */ +@Composable +fun rememberTopLevelDialogController( + stringProvider: AuthUIStringProvider, + authState: AuthState +): TopLevelDialogController { + return remember(stringProvider, authState) { + TopLevelDialogController(stringProvider, authState) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt new file mode 100644 index 0000000000..ab79e8954c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt @@ -0,0 +1,396 @@ +package com.firebase.ui.auth.ui.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.core.text.isDigitsOnly +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.FieldValidator + +@Composable +fun VerificationCodeInputField( + modifier: Modifier = Modifier, + codeLength: Int = 6, + validator: FieldValidator? = null, + isError: Boolean = false, + errorMessage: String? = null, + onCodeComplete: (String) -> Unit = {}, + onCodeChange: (String) -> Unit = {}, +) { + val code = remember { mutableStateOf(List(codeLength) { null }) } + val focusedIndex = remember { mutableStateOf(null) } + val focusRequesters = remember { (1..codeLength).map { FocusRequester() } } + val keyboardManager = LocalSoftwareKeyboardController.current + + // Derive validation state + val currentCodeString = remember { mutableStateOf("") } + val validationError = remember { mutableStateOf(null) } + + // Auto-focus first field on initial composition + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.requestFocus() + } + + // Handle focus changes + LaunchedEffect(focusedIndex.value) { + focusedIndex.value?.let { index -> + focusRequesters.getOrNull(index)?.requestFocus() + } + } + + // Handle code completion and validation + LaunchedEffect(code.value) { + val codeString = code.value.mapNotNull { it }.joinToString("") + currentCodeString.value = codeString + onCodeChange(codeString) + + // Run validation if validator is provided + validator?.let { + val isValid = it.validate(codeString) + validationError.value = if (!isValid && codeString.length == codeLength) { + it.errorMessage + } else { + null + } + } + + val allNumbersEntered = code.value.none { it == null } + if (allNumbersEntered) { + keyboardManager?.hide() + onCodeComplete(codeString) + } + } + + // Determine error state: use validator if provided, otherwise use explicit isError + val showError = if (validator != null) { + validationError.value != null + } else { + isError + } + + val displayErrorMessage = if (validator != null) { + validationError.value + } else { + errorMessage + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + code.value.forEachIndexed { index, number -> + SingleDigitField( + modifier = Modifier + .weight(1f) + .aspectRatio(1f), + number = number, + isError = showError, + focusRequester = focusRequesters[index], + onFocusChanged = { isFocused -> + if (isFocused) { + focusedIndex.value = index + } + }, + onNumberChanged = { value -> + val oldValue = code.value[index] + val newCode = code.value.toMutableList() + newCode[index] = value + code.value = newCode + + // Move focus to next field if number was entered (and field was previously empty) + if (value != null && oldValue == null) { + focusedIndex.value = getNextFocusedIndex(newCode, index) + } + }, + onKeyboardBack = { + val previousIndex = getPreviousFocusedIndex(index) + if (previousIndex != null) { + val newCode = code.value.toMutableList() + newCode[previousIndex] = null + code.value = newCode + focusedIndex.value = previousIndex + } + }, + onNumberEntered = { + focusRequesters[index].freeFocus() + } + ) + } + } + + if (showError && displayErrorMessage != null) { + Text( + modifier = Modifier.padding(top = 8.dp), + text = displayErrorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun SingleDigitField( + modifier: Modifier = Modifier, + number: Int?, + isError: Boolean = false, + focusRequester: FocusRequester, + onFocusChanged: (Boolean) -> Unit, + onNumberChanged: (Int?) -> Unit, + onKeyboardBack: () -> Unit, + onNumberEntered: () -> Unit, +) { + val text = remember { mutableStateOf(TextFieldValue()) } + val isFocused = remember { mutableStateOf(false) } + + // Update text field value when number changes externally + LaunchedEffect(number) { + text.value = TextFieldValue( + text = number?.toString().orEmpty(), + selection = TextRange( + index = if (number != null) 1 else 0 + ) + ) + } + + val borderColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + +// val backgroundColor = if (isError) { +// MaterialTheme.colorScheme.errorContainer +// } else { +// MaterialTheme.colorScheme.primaryContainer +// } + + val textColor = if (isError) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.primary + } + + val targetBorderWidth = if (isError || isFocused.value || number != null) 2.dp else 1.dp + val animatedBorderWidth by animateDpAsState( + targetValue = targetBorderWidth, + animationSpec = tween(durationMillis = 150), + label = "borderWidth" + ) + + val shape = RoundedCornerShape(8.dp) + + Box( + modifier = modifier + .clip(shape) + .border( + width = animatedBorderWidth, + shape = shape, + color = borderColor, + ), + //.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + BasicTextField( + modifier = Modifier + .fillMaxSize() + .wrapContentSize() + .semantics { + contentDescription = "Verification code digit" + } + .focusRequester(focusRequester) + .onFocusChanged { + isFocused.value = it.isFocused + onFocusChanged(it.isFocused) + } + .onPreviewKeyEvent { event -> + val isDelete = event.key == Key.Backspace || event.key == Key.Delete + val isInitialDown = event.type == KeyEventType.KeyDown && + event.nativeKeyEvent.repeatCount == 0 + + if (isDelete && isInitialDown && number == null) { + onKeyboardBack() + return@onPreviewKeyEvent true + } + false + }, + value = text.value, + onValueChange = { value -> + val newNumber = value.text + if (newNumber.length <= 1 && newNumber.isDigitsOnly()) { + val digit = newNumber.toIntOrNull() + onNumberChanged(digit) + if (digit != null) { + onNumberEntered() + } + } + }, + cursorBrush = SolidColor(textColor), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + textAlign = TextAlign.Center, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + color = textColor, + lineHeight = 24.sp, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + innerTextField() + } + } + ) + } +} + +private fun getPreviousFocusedIndex(currentIndex: Int): Int? { + return currentIndex.minus(1).takeIf { it >= 0 } +} + +private fun getNextFocusedIndex(code: List, currentIndex: Int): Int? { + if (currentIndex >= code.size - 1) return currentIndex + + for (i in (currentIndex + 1) until code.size) { + if (code[i] == null) { + return i + } + } + return currentIndex +} + +@Preview +@Composable +private fun PreviewVerificationCodeInputFieldExample() { + val completedCode = remember { mutableStateOf(null) } + val currentCode = remember { mutableStateOf("") } + val isError = remember { mutableStateOf(false) } + + AuthUITheme { + Scaffold( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp), + isError = isError.value, + errorMessage = if (isError.value) "Invalid verification code" else null, + onCodeComplete = { code -> + completedCode.value = code + // Simulate validation - in real app this would be async + isError.value = code != "123456" + }, + onCodeChange = { code -> + currentCode.value = code + // Clear error on change + if (isError.value) { + isError.value = false + } + } + ) + + if (!isError.value) { + completedCode.value?.let { code -> + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Code entered: $code", + color = MaterialTheme.colorScheme.primary, + fontSize = 16.sp, + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewVerificationCodeInputFieldError() { + AuthUITheme { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp), + isError = true, + errorMessage = "Invalid verification code" + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewVerificationCodeInputField() { + AuthUITheme { + VerificationCodeInputField( + modifier = Modifier.padding(16.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt new file mode 100644 index 0000000000..7cf0c8ce5c --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.method_picker + +import android.content.Context +import android.content.Intent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.core.net.toUri + +@Composable +internal fun AnnotatedStringResource( + context: Context, + modifier: Modifier = Modifier, + text: String, + vararg links: Pair, + inPreview: Boolean = false, + previewText: String? = null, +) { + val template = if (inPreview && previewText != null) { + previewText + } else { + text + } + + val annotated = buildAnnotatedString { + var currentIndex = 0 + + links.forEach { (label, url) -> + val start = template.indexOf(label, currentIndex).takeIf { it >= 0 } ?: return@forEach + + append(template.substring(currentIndex, start)) + + withLink( + LinkAnnotation.Url( + url, + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ) + ) + ) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + context.startActivity(intent) + } + ) { + append(label) + } + + currentIndex = start + label.length + } + + if (currentIndex < template.length) { + append(template.substring(currentIndex)) + } + } + + Text( + modifier = modifier, + text = annotated, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt new file mode 100644 index 0000000000..bf4c3b6a57 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.method_picker + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.R +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.ui.components.AuthProviderButton +import com.firebase.ui.auth.util.SignInPreferenceManager + +/** + * Renders the provider selection screen. + * + * **Example usage:** + * ```kotlin + * AuthMethodPicker( + * providers = listOf( + * AuthProvider.Google(), + * AuthProvider.Email(), + * ), + * onProviderSelected = { provider -> /* ... */ } + * ) + * ``` + * + * @param modifier A modifier for the screen layout. + * @param providers The list of providers to display. + * @param logo An optional logo to display. + * @param onProviderSelected A callback when a provider is selected. + * @param customLayout An optional custom layout composable for the provider buttons. + * @param termsOfServiceUrl The URL for the Terms of Service. + * @param privacyPolicyUrl The URL for the Privacy Policy. + * @param lastSignInPreference The last sign-in preference to show a "Continue as..." button. + * + * @since 10.0.0 + */ +@Composable +fun AuthMethodPicker( + modifier: Modifier = Modifier, + providers: List, + logo: AuthUIAsset? = null, + onProviderSelected: (AuthProvider) -> Unit, + customLayout: @Composable ((List, (AuthProvider) -> Unit) -> Unit)? = null, + termsOfServiceUrl: String? = null, + privacyPolicyUrl: String? = null, + lastSignInPreference: SignInPreferenceManager.SignInPreference? = null, +) { + val context = LocalContext.current + val inPreview = LocalInspectionMode.current + val stringProvider = LocalAuthUIStringProvider.current + + Column( + modifier = modifier + ) { + logo?.let { + Image( + modifier = Modifier + .weight(0.4f) + .align(Alignment.CenterHorizontally), + painter = it.painter, + contentDescription = if (inPreview) "" + else stringResource(R.string.fui_auth_method_picker_logo) + ) + } + if (customLayout != null) { + customLayout(providers, onProviderSelected) + } else { + BoxWithConstraints( + modifier = Modifier + .weight(1f), + ) { + val paddingWidth = maxWidth.value * 0.23 + LazyColumn( + modifier = Modifier + .padding(horizontal = paddingWidth.dp) + .testTag("AuthMethodPicker LazyColumn"), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Show "Continue as..." button if last sign-in preference exists + lastSignInPreference?.let { preference -> + val lastProvider = providers.find { it.providerId == preference.providerId } + if (lastProvider != null) { + item { + ContinueAsButton( + provider = lastProvider, + identifier = preference.identifier, + onClick = { onProviderSelected(lastProvider) } + ) + Spacer(modifier = Modifier.height(24.dp)) + + // Divider with "or" + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringProvider.orContinueWith, + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + // Show all providers + itemsIndexed(providers) { index, provider -> + Box( + modifier = Modifier + .padding(bottom = if (index < providers.lastIndex) 16.dp else 0.dp) + ) { + AuthProviderButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + onProviderSelected(provider) + }, + provider = provider, + stringProvider = LocalAuthUIStringProvider.current + ) + } + } + } + } + } + AnnotatedStringResource( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + context = context, + inPreview = inPreview, + previewText = "By continuing, you accept our Terms of Service and Privacy Policy.", + text = stringProvider.tosAndPrivacyPolicy( + termsOfServiceLabel = stringProvider.termsOfService, + privacyPolicyLabel = stringProvider.privacyPolicy + ), + links = arrayOf( + stringProvider.termsOfService to (termsOfServiceUrl ?: ""), + stringProvider.privacyPolicy to (privacyPolicyUrl ?: "") + ) + ) + } +} + +/** + * A prominent "Continue as..." button that shows the last-used provider and identifier. + * + * @param provider The authentication provider + * @param identifier The user identifier (email, phone number, etc.) + * @param onClick Callback when the button is clicked + */ +@Composable +private fun ContinueAsButton( + provider: AuthProvider, + identifier: String?, + onClick: () -> Unit +) { + val stringProvider = LocalAuthUIStringProvider.current + + AuthProviderButton( + modifier = Modifier + .fillMaxWidth() + .testTag("ContinueAsButton"), + onClick = onClick, + provider = provider, + stringProvider = stringProvider, + subtitle = identifier, + showAsContinue = true + ) +} + +@Preview(showBackground = true) +@Composable +fun PreviewAuthMethodPicker() { + Column( + modifier = Modifier + .fillMaxSize() + ) { + AuthMethodPicker( + providers = listOf( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ), + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + ), + AuthProvider.Google( + scopes = emptyList(), + serverClientId = null + ), + AuthProvider.Facebook(), + AuthProvider.Twitter( + customParameters = emptyMap() + ), + AuthProvider.Github( + customParameters = emptyMap() + ), + AuthProvider.Microsoft( + tenant = null, + customParameters = emptyMap() + ), + AuthProvider.Yahoo( + customParameters = emptyMap() + ), + AuthProvider.Apple( + locale = null, + customParameters = emptyMap() + ), + AuthProvider.Anonymous, + ), + logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), + onProviderSelected = { provider -> + + }, + termsOfServiceUrl = "https://example.com/terms", + privacyPolicyUrl = "https://example.com/privacy" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt new file mode 100644 index 0000000000..5a065400cd --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -0,0 +1,861 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.BuildConfig +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler +import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler +import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler +import com.firebase.ui.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher +import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController +import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker +import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen +import com.firebase.ui.auth.util.EmailLinkPersistenceManager +import com.firebase.ui.auth.util.SignInPreferenceManager +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.MultiFactorResolver +import kotlinx.coroutines.launch + +/** + * High-level authentication screen that wires together provider selection, individual provider + * flows, error handling, and multi-factor enrollment/challenge flows. Back navigation is driven by + * the Jetpack Navigation stack so presses behave like native Android navigation. + * + * @param authenticatedContent Optional slot that allows callers to render the authenticated + * state themselves. When provided, it receives the current [AuthState] alongside an + * [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user). + * + * @since 10.0.0 + */ +@Composable +fun FirebaseAuthScreen( + configuration: AuthUIConfiguration, + onSignInSuccess: (AuthResult) -> Unit, + onSignInFailure: (AuthException) -> Unit, + onSignInCancelled: () -> Unit, + modifier: Modifier = Modifier, + authUI: FirebaseAuthUI = FirebaseAuthUI.getInstance(), + emailLink: String? = null, + mfaConfiguration: MfaConfiguration = MfaConfiguration(), + authenticatedContent: (@Composable (state: AuthState, uiContext: AuthSuccessUiContext) -> Unit)? = null, +) { + // Set FirebaseUI version + LaunchedEffect(authUI.auth) { + authUI.auth.setFirebaseUIVersion(BuildConfig.VERSION_NAME) + } + + val activity = LocalActivity.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val stringProvider = DefaultAuthUIStringProvider(context) + val navController = rememberNavController() + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val dialogController = rememberTopLevelDialogController(stringProvider, authState) + val lastSuccessfulUserId = remember { mutableStateOf(null) } + val pendingLinkingCredential = remember { mutableStateOf(null) } + val pendingResolver = remember { mutableStateOf(null) } + val emailLinkFromDifferentDevice = remember { mutableStateOf(null) } + val lastSignInPreference = + remember { mutableStateOf(null) } + + // Load last sign-in preference on launch + LaunchedEffect(authState) { + lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context) + } + + val anonymousProvider = + configuration.providers.filterIsInstance().firstOrNull() + val googleProvider = + configuration.providers.filterIsInstance().firstOrNull() + val emailProvider = configuration.providers.filterIsInstance().firstOrNull() + val facebookProvider = + configuration.providers.filterIsInstance().firstOrNull() + val appleProvider = configuration.providers.filterIsInstance().firstOrNull() + val githubProvider = + configuration.providers.filterIsInstance().firstOrNull() + val microsoftProvider = + configuration.providers.filterIsInstance().firstOrNull() + val yahooProvider = configuration.providers.filterIsInstance().firstOrNull() + val twitterProvider = + configuration.providers.filterIsInstance().firstOrNull() + val genericOAuthProviders = + configuration.providers.filterIsInstance() + + val logoAsset = configuration.logo + + val onSignInAnonymously = anonymousProvider?.let { + authUI.rememberAnonymousSignInHandler() + } + + val onSignInWithGoogle = googleProvider?.let { + authUI.rememberGoogleSignInHandler( + context = context, + config = configuration, + provider = it + ) + } + + val onSignInWithFacebook = facebookProvider?.let { + authUI.rememberSignInWithFacebookLauncher( + context = context, + config = configuration, + provider = it + ) + } + + val onSignInWithApple = appleProvider?.let { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithGithub = githubProvider?.let { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithMicrosoft = microsoftProvider?.let { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithYahoo = yahooProvider?.let { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + val onSignInWithTwitter = twitterProvider?.let { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + val genericOAuthHandlers = genericOAuthProviders.associateWith { + authUI.rememberOAuthSignInHandler( + context = context, + activity = activity, + config = configuration, + provider = it + ) + } + + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider, + LocalTopLevelDialogController provides dialogController, + LocalAuthUITheme provides (configuration.theme ?: LocalAuthUITheme.current) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + ) { + NavHost( + navController = navController, + startDestination = AuthRoute.MethodPicker.route, + enterTransition = configuration.transitions?.enterTransition ?: { + fadeIn(animationSpec = tween(700)) + }, + exitTransition = configuration.transitions?.exitTransition ?: { + fadeOut(animationSpec = tween(700)) + }, + popEnterTransition = configuration.transitions?.popEnterTransition ?: { + fadeIn(animationSpec = tween(700)) + }, + popExitTransition = configuration.transitions?.popExitTransition ?: { + fadeOut(animationSpec = tween(700)) + } + ) { + composable(AuthRoute.MethodPicker.route) { + Scaffold { innerPadding -> + AuthMethodPicker( + modifier = modifier + .padding(innerPadding), + providers = configuration.providers, + logo = logoAsset, + termsOfServiceUrl = configuration.tosUrl, + privacyPolicyUrl = configuration.privacyPolicyUrl, + lastSignInPreference = lastSignInPreference.value, + onProviderSelected = { provider -> + when (provider) { + is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() + + is AuthProvider.Email -> { + navController.navigate(AuthRoute.Email.route) + } + + is AuthProvider.Phone -> { + navController.navigate(AuthRoute.Phone.route) + } + + is AuthProvider.Google -> onSignInWithGoogle?.invoke() + + is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() + + is AuthProvider.Apple -> onSignInWithApple?.invoke() + + is AuthProvider.Github -> onSignInWithGithub?.invoke() + + is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() + + is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() + + is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() + + is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() + + else -> { + onSignInFailure( + AuthException.UnknownException( + message = "Provider ${provider.providerId} is not supported in FirebaseAuthScreen", + cause = IllegalArgumentException( + "Provider ${provider.providerId} is not supported in FirebaseAuthScreen" + ) + ) + ) + } + } + } + ) + } + } + + composable(AuthRoute.Email.route) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + credentialForLinking = pendingLinkingCredential.value, + emailLinkFromDifferentDevice = emailLinkFromDifferentDevice.value, + onSuccess = { + pendingLinkingCredential.value = null + }, + onError = { exception -> + onSignInFailure(exception) + }, + onCancel = { + pendingLinkingCredential.value = null + if (!navController.popBackStack()) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + ) + } + + composable(AuthRoute.Phone.route) { + PhoneAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = {}, + onError = { exception -> + onSignInFailure(exception) + }, + onCancel = { + if (!navController.popBackStack()) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + ) + } + + composable(AuthRoute.Success.route) { + val uiContext = remember(authState, stringProvider) { + AuthSuccessUiContext( + authUI = authUI, + stringProvider = stringProvider, + configuration = configuration, + onSignOut = { + coroutineScope.launch { + try { + authUI.signOut(context) + // Keep sign-in preference for "Continue as..." on next launch + } catch (e: Exception) { + onSignInFailure(AuthException.from(e)) + } finally { + pendingLinkingCredential.value = null + pendingResolver.value = null + } + } + }, + onManageMfa = { + if (configuration.isMfaEnabled) { + navController.navigate(AuthRoute.MfaEnrollment.route) + } else { + val exception = AuthException.AuthCancelledException( + message = "Multi-factor authentication is disabled in the configuration. " + + "Enable MFA in AuthUIConfiguration to use this feature." + ) + authUI.updateAuthState(AuthState.Error(exception)) + } + }, + onReloadUser = { + coroutineScope.launch { + try { + // Reload user to get fresh data from server + authUI.getCurrentUser()?.reload() + authUI.getCurrentUser()?.getIdToken(true) + + // Check the user's email verification status after reload + val user = authUI.getCurrentUser() + if (user != null) { + // If email is now verified, transition to Success state + if (user.isEmailVerified) { + authUI.updateAuthState( + AuthState.Success( + result = null, + user = user, + isNewUser = false + ) + ) + } else { + // Email still not verified, keep showing verification screen + authUI.updateAuthState( + AuthState.RequiresEmailVerification( + user = user, + email = user.email ?: "" + ) + ) + } + } + } catch (e: Exception) { + Log.e("FirebaseAuthScreen", "Failed to refresh user", e) + } + } + }, + onNavigate = { route -> + navController.navigate(route.route) + } + ) + } + + if (authenticatedContent != null) { + authenticatedContent(authState, uiContext) + } else { + SuccessDestination( + authState = authState, + stringProvider = stringProvider, + configuration = configuration, + uiContext = uiContext + ) + } + } + + composable(AuthRoute.MfaEnrollment.route) { + val user = authUI.getCurrentUser() + if (user != null) { + MfaEnrollmentScreen( + user = user, + auth = authUI.auth, + configuration = mfaConfiguration, + authConfiguration = configuration, + onComplete = { navController.popBackStack() }, + onSkip = { navController.popBackStack() }, + onError = { exception -> + onSignInFailure(AuthException.from(exception)) + } + ) + } else { + navController.popBackStack() + } + } + + composable(AuthRoute.MfaChallenge.route) { + val resolver = pendingResolver.value + if (resolver != null) { + MfaChallengeScreen( + resolver = resolver, + auth = authUI.auth, + onSuccess = { + pendingResolver.value = null + // Reset auth state to Idle so the firebaseAuthFlow Success state takes over + authUI.updateAuthState(AuthState.Idle) + }, + onCancel = { + pendingResolver.value = null + authUI.updateAuthState(AuthState.Cancelled) + navController.popBackStack() + }, + onError = { exception -> + onSignInFailure(AuthException.from(exception)) + } + ) + } else { + navController.popBackStack() + } + } + } + + // Handle email link sign-in (deep links) + LaunchedEffect(emailLink) { + if (emailLink != null && emailProvider != null) { + try { + // Try to retrieve saved email from DataStore (same-device flow) + val savedEmail = + EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email + + if (savedEmail != null) { + // Same device - we have the email, sign in automatically + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = emailProvider, + email = savedEmail, + emailLink = emailLink + ) + } else { + // Different device - no saved email + // Call signInWithEmailLink with empty email to trigger validation + // This will throw EmailLinkPromptForEmailException or EmailLinkWrongDeviceException + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = emailProvider, + email = "", // Empty email triggers cross-device detection + emailLink = emailLink + ) + } + } catch (e: Exception) { + Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e) + } + } + } + + // Synchronise auth state changes with navigation stack. + LaunchedEffect(authState) { + val state = authState + val currentRoute = navController.currentBackStackEntry?.destination?.route + when (state) { + is AuthState.Success -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + + state.result?.let { result -> + if (state.user.uid != lastSuccessfulUserId.value) { + onSignInSuccess(result) + lastSuccessfulUserId.value = state.user.uid + + // Reload sign-in preference (may have been updated by provider) + coroutineScope.launch { + lastSignInPreference.value = + SignInPreferenceManager.getLastSignIn(context) + } + } + } + + if (currentRoute != AuthRoute.Success.route) { + navController.navigate(AuthRoute.Success.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + is AuthState.RequiresEmailVerification, + is AuthState.RequiresProfileCompletion, + -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + if (currentRoute != AuthRoute.Success.route) { + navController.navigate(AuthRoute.Success.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + is AuthState.RequiresMfa -> { + pendingResolver.value = state.resolver + if (currentRoute != AuthRoute.MfaChallenge.route) { + navController.navigate(AuthRoute.MfaChallenge.route) { + launchSingleTop = true + } + } + } + + is AuthState.Cancelled -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + if (currentRoute != AuthRoute.MethodPicker.route) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + onSignInCancelled() + } + + is AuthState.Idle -> { + pendingResolver.value = null + pendingLinkingCredential.value = null + lastSuccessfulUserId.value = null + if (currentRoute != AuthRoute.MethodPicker.route) { + navController.navigate(AuthRoute.MethodPicker.route) { + popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } + launchSingleTop = true + } + } + } + + else -> Unit + } + } + + // Handle errors using top-level dialog controller + val errorState = authState as? AuthState.Error + if (errorState != null) { + LaunchedEffect(errorState) { + val exception = when (val throwable = errorState.exception) { + is AuthException -> throwable + else -> AuthException.from(throwable) + } + + dialogController.showErrorDialog( + exception = exception, + onRetry = { _ -> + // Child screens handle their own retry logic + }, + onRecover = { exception -> + when (exception) { + is AuthException.EmailAlreadyInUseException -> { + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.AccountLinkingRequiredException -> { + pendingLinkingCredential.value = exception.credential + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.EmailLinkPromptForEmailException -> { + // Cross-device flow: User needs to enter their email + emailLinkFromDifferentDevice.value = exception.emailLink + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + is AuthException.EmailLinkCrossDeviceLinkingException -> { + // Cross-device linking flow: User needs to enter email to link provider + emailLinkFromDifferentDevice.value = exception.emailLink + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } + } + + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + } + + // Render the top-level dialog (only one instance) + dialogController.CurrentDialog() + + val loadingState = authState as? AuthState.Loading + if (loadingState != null) { + LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) + } + } + } +} + +sealed class AuthRoute(val route: String) { + object MethodPicker : AuthRoute("auth_method_picker") + object Email : AuthRoute("auth_email") + object Phone : AuthRoute("auth_phone") + object Success : AuthRoute("auth_success") + object MfaEnrollment : AuthRoute("auth_mfa_enrollment") + object MfaChallenge : AuthRoute("auth_mfa_challenge") +} + +data class AuthSuccessUiContext( + val authUI: FirebaseAuthUI, + val stringProvider: AuthUIStringProvider, + val configuration: AuthUIConfiguration, + val onSignOut: () -> Unit, + val onManageMfa: () -> Unit, + val onReloadUser: () -> Unit, + val onNavigate: (AuthRoute) -> Unit, +) + +@Composable +private fun SuccessDestination( + authState: AuthState, + stringProvider: AuthUIStringProvider, + configuration: AuthUIConfiguration, + uiContext: AuthSuccessUiContext, +) { + when (authState) { + is AuthState.Success -> { + AuthSuccessContent( + authUI = uiContext.authUI, + stringProvider = stringProvider, + configuration = configuration, + onSignOut = uiContext.onSignOut, + onManageMfa = uiContext.onManageMfa + ) + } + + is AuthState.RequiresEmailVerification -> { + EmailVerificationContent( + authUI = uiContext.authUI, + stringProvider = stringProvider, + onCheckStatus = uiContext.onReloadUser, + onSignOut = uiContext.onSignOut + ) + } + + is AuthState.RequiresProfileCompletion -> { + ProfileCompletionContent( + missingFields = authState.missingFields, + stringProvider = stringProvider + ) + } + + else -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AuthSuccessContent( + authUI: FirebaseAuthUI, + stringProvider: AuthUIStringProvider, + configuration: AuthUIConfiguration, + onSignOut: () -> Unit, + onManageMfa: () -> Unit, +) { + val user = authUI.getCurrentUser() + val userIdentifier = user?.email ?: user?.phoneNumber ?: user?.uid.orEmpty() + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (userIdentifier.isNotBlank()) { + Text( + text = stringProvider.signedInAs(userIdentifier), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (user != null && authUI.auth.app.options.projectId != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { + PlainTooltip { + Text(stringProvider.mfaDisabledTooltip) + } + }, + state = rememberTooltipState( + initialIsVisible = !configuration.isMfaEnabled + ) + ) { + Button( + onClick = onManageMfa, + enabled = configuration.isMfaEnabled + ) { + Text(stringProvider.manageMfaAction) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + Button(onClick = onSignOut) { + Text(stringProvider.signOutAction) + } + } +} + +@Composable +private fun EmailVerificationContent( + authUI: FirebaseAuthUI, + stringProvider: AuthUIStringProvider, + onCheckStatus: () -> Unit, + onSignOut: () -> Unit, +) { + val user = authUI.getCurrentUser() + val emailLabel = user?.email ?: stringProvider.emailProvider + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringProvider.verifyEmailInstruction(emailLabel), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { user?.sendEmailVerification() }) { + Text(stringProvider.resendVerificationEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onCheckStatus) { + Text(stringProvider.verifiedEmailAction) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onSignOut) { + Text(stringProvider.signOutAction) + } + } +} + +@Composable +private fun ProfileCompletionContent( + missingFields: List, + stringProvider: AuthUIStringProvider, +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringProvider.profileCompletionMessage, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + if (missingFields.isNotEmpty()) { + Text( + text = stringProvider.profileMissingFieldsMessage(missingFields.joinToString()), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun LoadingDialog(message: String) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + containerColor = Color.Transparent, + text = { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + textAlign = TextAlign.Center, + color = Color.White + ) + } + } + ) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt new file mode 100644 index 0000000000..bca4bc4c5e --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.components.VerificationCodeInputField + +@Composable +internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { + val isSms = state.factorType == MfaFactor.Sms + val stringProvider = LocalAuthUIStringProvider.current + val verificationCodeValidator = remember { + VerificationCodeValidator(stringProvider) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = if (isSms) { + val phoneLabel = state.maskedPhoneNumber ?: "" + stringProvider.enterVerificationCodeTitle(phoneLabel) + } else { + stringProvider.mfaStepVerifyFactorTitle + }, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + if (isSms && state.maskedPhoneNumber != null) { + Text( + text = stringProvider.mfaStepVerifyFactorSmsHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (state.error != null) { + Text( + text = state.error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + codeLength = 6, + validator = verificationCodeValidator, + isError = state.error != null, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (isSms) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { state.onResendCodeClick?.invoke() }, + enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 + ) { + Text( + text = if (state.resendTimer > 0) { + val minutes = state.resendTimer / 60 + val seconds = state.resendTimer % 60 + val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}" + stringProvider.resendCodeTimer(formatted) + } else { + stringProvider.resendCode + } + ) + } + + TextButton( + onClick = state.onCancelClick, + enabled = !state.isLoading + ) { + Text(stringProvider.useDifferentMethodAction) + } + } + } else { + OutlinedButton( + onClick = state.onCancelClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringProvider.dismissAction) + } + } + + Button( + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Text(stringProvider.verifyAction) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt new file mode 100644 index 0000000000..2dab06adbf --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.mfa.SmsEnrollmentHandler +import com.firebase.ui.auth.mfa.maskPhoneNumber +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.MultiFactorResolver +import com.google.firebase.auth.PhoneAuthOptions +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.auth.PhoneMultiFactorGenerator +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorGenerator +import com.google.firebase.auth.TotpMultiFactorInfo +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import java.util.concurrent.TimeUnit + +/** + * A stateful composable that manages the Multi-Factor Authentication (MFA) challenge flow + * when a user attempts to sign in with MFA enabled. + * + * This screen is displayed when an [AuthState.RequiresMfa] state is encountered during sign-in. + * It handles the verification of the user's second factor (SMS or TOTP) and completes the + * sign-in process upon successful verification. + * + * **Challenge Flow:** + * 1. Screen detects available MFA factors from the resolver + * 2. For SMS: automatically sends verification code and shows masked phone number + * 3. For TOTP: prompts user to enter code from authenticator app + * 4. User enters verification code + * 5. System verifies code and completes sign-in + * + * @param resolver The [MultiFactorResolver] containing MFA session and available factors + * @param auth The [FirebaseAuth] instance + * @param onSuccess Callback invoked when MFA challenge completes successfully + * @param onCancel Callback invoked when user cancels the MFA challenge + * @param onError Callback invoked when an error occurs during verification + * @param content A composable lambda that receives [MfaChallengeContentState] to render custom UI + * + * @since 10.0.0 + */ +@Composable +fun MfaChallengeScreen( + resolver: MultiFactorResolver, + auth: FirebaseAuth, + onSuccess: (AuthResult) -> Unit, + onCancel: () -> Unit, + onError: (Exception) -> Unit = {}, + content: @Composable ((MfaChallengeContentState) -> Unit)? = null +) { + val coroutineScope = rememberCoroutineScope() + + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(null) } + val verificationCode = rememberSaveable { mutableStateOf("") } + val verificationId = remember { mutableStateOf(null) } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + val hints = resolver.hints + val firstHint = hints.firstOrNull() + + val factorType = remember { + when (firstHint?.factorId) { + PhoneMultiFactorGenerator.FACTOR_ID -> MfaFactor.Sms + TotpMultiFactorGenerator.FACTOR_ID -> MfaFactor.Totp + else -> MfaFactor.Sms + } + } + + val maskedPhoneNumber = remember { + if (firstHint is PhoneMultiFactorInfo) { + maskPhoneNumber(firstHint.phoneNumber) + } else null + } + + LaunchedEffect(firstHint) { + if (firstHint is PhoneMultiFactorInfo) { + isLoading.value = true + try { + val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { + coroutineScope.launch { + try { + val assertion = PhoneMultiFactorGenerator.getAssertion(credential) + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + } catch (e: Exception) { + error.value = e.message + onError(e) + } + } + } + + override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { + error.value = e.message + onError(e) + isLoading.value = false + } + + override fun onCodeSent( + verId: String, + token: PhoneAuthProvider.ForceResendingToken + ) { + verificationId.value = verId + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + isLoading.value = false + } + } + + val options = PhoneAuthOptions.newBuilder() + .setMultiFactorHint(firstHint) + .setMultiFactorSession(resolver.session) + .setCallbacks(callbacks) + .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + PhoneAuthProvider.verifyPhoneNumber(options) + } catch (e: Exception) { + error.value = e.message + onError(e) + isLoading.value = false + } + } + } + + val state = MfaChallengeContentState( + factorType = factorType, + maskedPhoneNumber = maskedPhoneNumber, + isLoading = isLoading.value, + error = error.value, + verificationCode = verificationCode.value, + resendTimer = resendTimerSeconds.intValue, + onVerificationCodeChange = { code -> + verificationCode.value = code + error.value = null + }, + onVerifyClick = { + coroutineScope.launch { + isLoading.value = true + try { + val assertion = when (factorType) { + MfaFactor.Sms -> { + val verId = verificationId.value + require(verId != null) { "No verification ID available" } + val credential = PhoneAuthProvider.getCredential( + verId, + verificationCode.value + ) + PhoneMultiFactorGenerator.getAssertion(credential) + } + MfaFactor.Totp -> { + val totpInfo = firstHint as? TotpMultiFactorInfo + require(totpInfo != null) { "No TOTP info available" } + TotpMultiFactorGenerator.getAssertionForSignIn( + totpInfo.uid, + verificationCode.value + ) + } + } + + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + error.value = null + } catch (e: Exception) { + error.value = e.message + onError(e) + } finally { + isLoading.value = false + } + } + }, + onResendCodeClick = if (factorType == MfaFactor.Sms && firstHint is PhoneMultiFactorInfo) { + { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + isLoading.value = true + try { + val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { + override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { + coroutineScope.launch { + try { + val assertion = PhoneMultiFactorGenerator.getAssertion(credential) + val result = resolver.resolveSignIn(assertion).await() + onSuccess(result) + } catch (e: Exception) { + error.value = e.message + onError(e) + } + } + } + + override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { + error.value = e.message + onError(e) + isLoading.value = false + } + + override fun onCodeSent( + verId: String, + token: PhoneAuthProvider.ForceResendingToken + ) { + verificationId.value = verId + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + isLoading.value = false + } + } + + val options = PhoneAuthOptions.newBuilder() + .setMultiFactorHint(firstHint) + .setMultiFactorSession(resolver.session) + .setCallbacks(callbacks) + .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + PhoneAuthProvider.verifyPhoneNumber(options) + } catch (e: Exception) { + error.value = e.message + onError(e) + isLoading.value = false + } + } + } + } + } else null, + onCancelClick = onCancel + ) + + if (content != null) { + content(state) + } else { + DefaultMfaChallengeContent(state) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt new file mode 100644 index 0000000000..61f1151a60 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -0,0 +1,638 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.mfa.toMfaErrorMessage +import com.firebase.ui.auth.ui.components.QrCodeImage +import com.firebase.ui.auth.ui.components.ReauthenticationDialog +import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI +import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.auth.MultiFactorInfo +import com.google.firebase.auth.PhoneMultiFactorInfo +import com.google.firebase.auth.TotpMultiFactorInfo + +@Composable +internal fun DefaultMfaEnrollmentContent( + state: MfaEnrollmentContentState, + authConfiguration: AuthUIConfiguration, + user: FirebaseUser +) { + val stringProvider = LocalAuthUIStringProvider.current + val snackbarHostState = remember { SnackbarHostState() } + val showReauthDialog = remember { mutableStateOf(false) } + val reauthErrorMessage = remember { mutableStateOf(null) } + val successMessage = remember { mutableStateOf(null) } + + LaunchedEffect(state.error, state.exception) { + val exception = state.exception + when { + exception is FirebaseAuthRecentLoginRequiredException -> { + showReauthDialog.value = true + } + exception != null -> { + snackbarHostState.showSnackbar(exception.toMfaErrorMessage(stringProvider)) + } + !state.error.isNullOrBlank() -> { + snackbarHostState.showSnackbar(state.error!!) + } + } + } + + LaunchedEffect(successMessage.value) { + successMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + successMessage.value = null + } + } + + LaunchedEffect(reauthErrorMessage.value) { + reauthErrorMessage.value?.let { message -> + snackbarHostState.showSnackbar(message) + reauthErrorMessage.value = null + } + } + + if (showReauthDialog.value) { + ReauthenticationDialog( + user = user, + onDismiss = { + showReauthDialog.value = false + }, + onSuccess = { + showReauthDialog.value = false + successMessage.value = stringProvider.identityVerifiedMessage + }, + onError = { exception -> + reauthErrorMessage.value = when { + exception.message?.contains("password", ignoreCase = true) == true -> + stringProvider.incorrectPasswordError + exception.message?.contains("network", ignoreCase = true) == true -> + stringProvider.noInternet + else -> stringProvider.reauthGenericError + } + } + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + when (state.step) { + MfaEnrollmentStep.SelectFactor -> { + SelectFactorUI( + availableFactors = state.availableFactors, + enrolledFactors = state.enrolledFactors, + onFactorSelected = state.onFactorSelected, + onUnenrollFactor = state.onUnenrollFactor, + onSkipClick = state.onSkipClick, + isLoading = state.isLoading, + error = state.error, + stringProvider = stringProvider + ) + } + + MfaEnrollmentStep.ConfigureSms -> { + state.selectedCountry?.let { country -> + EnterPhoneNumberUI( + configuration = authConfiguration, + isLoading = state.isLoading, + phoneNumber = state.phoneNumber, + selectedCountry = country, + onPhoneNumberChange = state.onPhoneNumberChange, + onCountrySelected = state.onCountrySelected, + onSendCodeClick = state.onSendSmsCodeClick, + title = stringProvider.mfaEnrollmentEnterPhoneNumber + ) + } + } + + MfaEnrollmentStep.ConfigureTotp -> { + ConfigureTotpUI( + totpSecret = state.totpSecret?.sharedSecretKey, + totpQrCodeUrl = state.totpQrCodeUrl, + onContinueClick = state.onContinueToVerifyClick, + onBackClick = state.onBackClick, + isLoading = state.isLoading, + isValid = state.isValid, + error = state.error, + stringProvider = stringProvider + ) + } + + MfaEnrollmentStep.VerifyFactor -> { + when (state.selectedFactor) { + MfaFactor.Sms -> { + val formattedPhone = + "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}" + EnterVerificationCodeUI( + configuration = authConfiguration, + isLoading = state.isLoading, + verificationCode = state.verificationCode, + fullPhoneNumber = formattedPhone, + resendTimer = state.resendTimer, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyCodeClick = state.onVerifyClick, + onResendCodeClick = state.onResendCodeClick ?: {}, + onChangeNumberClick = state.onBackClick, + title = stringProvider.mfaEnrollmentVerifySmsCode + ) + } + + MfaFactor.Totp -> { + VerifyTotpUI( + verificationCode = state.verificationCode, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyClick = state.onVerifyClick, + onBackClick = state.onBackClick, + isLoading = state.isLoading, + isValid = state.isValid, + error = state.error, + stringProvider = stringProvider + ) + } + + null -> Unit + } + } + + MfaEnrollmentStep.ShowRecoveryCodes -> { + ShowRecoveryCodesUI( + recoveryCodes = state.recoveryCodes.orEmpty(), + onDoneClick = state.onCodesSavedClick, + isLoading = state.isLoading, + error = state.error, + stringProvider = stringProvider + ) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun SelectFactorUI( + availableFactors: List, + enrolledFactors: List, + onFactorSelected: (MfaFactor) -> Unit, + onUnenrollFactor: (MultiFactorInfo) -> Unit, + onSkipClick: (() -> Unit)?, + isLoading: Boolean, + error: String?, + stringProvider: AuthUIStringProvider +) { + val enrolledFactorIds = enrolledFactors.mapNotNull { + when (it) { + is PhoneMultiFactorInfo -> MfaFactor.Sms + is TotpMultiFactorInfo -> MfaFactor.Totp + else -> null + } + }.toSet() + + val factorsToEnroll = availableFactors.filter { it !in enrolledFactorIds } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringProvider.mfaManageFactorsTitle) } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaManageFactorsDescription, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + if (enrolledFactors.isNotEmpty()) { + Text( + text = stringProvider.mfaActiveMethodsTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + enrolledFactors.forEach { factorInfo -> + EnrolledFactorItem( + factorInfo = factorInfo, + onRemove = { onUnenrollFactor(factorInfo) }, + enabled = !isLoading, + stringProvider = stringProvider + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + if (factorsToEnroll.isNotEmpty()) { + Text( + text = stringProvider.mfaAddNewMethodTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + factorsToEnroll.forEach { factor -> + Button( + onClick = { onFactorSelected(factor) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + when (factor) { + MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle) + MfaFactor.Totp -> Text(stringProvider.mfaStepConfigureTotpTitle) + } + } + } + } else if (enrolledFactors.isNotEmpty()) { + Text( + text = stringProvider.mfaAllMethodsEnrolledMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + onSkipClick?.let { + TextButton( + onClick = it, + enabled = !isLoading + ) { + Text(stringProvider.skipAction) + } + } + } + } +} + +@Composable +private fun EnrolledFactorItem( + factorInfo: MultiFactorInfo, + onRemove: () -> Unit, + enabled: Boolean, + stringProvider: AuthUIStringProvider +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = when (factorInfo) { + is PhoneMultiFactorInfo -> stringProvider.smsAuthenticationLabel + is TotpMultiFactorInfo -> stringProvider.totpAuthenticationLabel + else -> stringProvider.unknownMethodLabel + }, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = when (factorInfo) { + is PhoneMultiFactorInfo -> factorInfo.phoneNumber ?: stringProvider.smsAuthenticationLabel + is TotpMultiFactorInfo -> factorInfo.displayName ?: stringProvider.totpAuthenticationLabel + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringProvider.enrolledOnDateLabel( + java.text.SimpleDateFormat( + "MMM dd, yyyy", + java.util.Locale.getDefault() + ).format(java.util.Date(factorInfo.enrollmentTimestamp * 1000)) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + OutlinedButton( + onClick = onRemove, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringProvider.removeAction) + } + } + } +} + +@Composable +private fun ConfigureTotpUI( + totpSecret: String?, + totpQrCodeUrl: String?, + onContinueClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String?, + stringProvider: AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepConfigureTotpTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.setupAuthenticatorDescription, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + totpQrCodeUrl?.let { url -> + QrCodeImage( + content = url, + size = 220.dp + ) + } + + totpSecret?.let { secret -> + Text( + text = stringProvider.secretKeyLabel, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = secret, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + onClick = onBackClick, + enabled = !isLoading, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.backAction) + } + + Button( + onClick = onContinueClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.continueText) + } + } + } + } +} + +@Composable +private fun VerifyTotpUI( + verificationCode: String, + onVerificationCodeChange: (String) -> Unit, + onVerifyClick: () -> Unit, + onBackClick: () -> Unit, + isLoading: Boolean, + isValid: Boolean, + error: String?, + stringProvider: AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepVerifyFactorTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.mfaStepVerifyFactorTotpHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + OutlinedTextField( + value = verificationCode, + onValueChange = onVerificationCodeChange, + label = { Text(stringProvider.verificationCodeLabel) }, + enabled = !isLoading, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onBackClick, + enabled = !isLoading, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.backAction) + } + + Button( + onClick = onVerifyClick, + enabled = !isLoading && isValid, + modifier = Modifier.weight(1f) + ) { + Text(stringProvider.verifyAction) + } + } + } + } +} + +@Composable +private fun ShowRecoveryCodesUI( + recoveryCodes: List, + onDoneClick: () -> Unit, + isLoading: Boolean, + error: String?, + stringProvider: AuthUIStringProvider +) { + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringProvider.mfaStepShowRecoveryCodesTitle, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Text( + text = stringProvider.mfaStepShowRecoveryCodesHelper, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + recoveryCodes.forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + Button( + onClick = onDoneClick, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringProvider.recoveryCodesSavedAction) + } + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt new file mode 100644 index 0000000000..29f6827c9e --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt @@ -0,0 +1,396 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.util.CountryUtils +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebase.ui.auth.mfa.SmsEnrollmentHandler +import com.firebase.ui.auth.mfa.SmsEnrollmentSession +import com.firebase.ui.auth.mfa.TotpEnrollmentHandler +import com.firebase.ui.auth.mfa.TotpSecret +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A stateful composable that manages the Multi-Factor Authentication (MFA) enrollment flow. + * + * This screen handles all steps of MFA enrollment including factor selection, configuration, + * verification, and recovery code display. It uses the provided handlers to communicate with + * Firebase Authentication and exposes state through a content slot for custom UI rendering. + * + * **Enrollment Flow:** + * 1. **SelectFactor** - User chooses between SMS or TOTP + * 2. **ConfigureSms** or **ConfigureTotp** - User sets up their chosen factor + * 3. **VerifyFactor** - User verifies with a code + * 4. **ShowRecoveryCodes** - (Optional) User receives backup codes + * + * @param user The currently authenticated [FirebaseUser] to enroll in MFA + * @param auth The [FirebaseAuth] instance + * @param configuration MFA configuration controlling available factors and behavior + * @param onComplete Callback invoked when enrollment completes successfully + * @param onSkip Callback invoked when user skips enrollment (only if not required) + * @param onError Callback invoked when an error occurs during enrollment + * @param content A composable lambda that receives [MfaEnrollmentContentState] to render custom UI + * + * @since 10.0.0 + */ +@Composable +fun MfaEnrollmentScreen( + user: FirebaseUser, + auth: FirebaseAuth, + configuration: MfaConfiguration, + authConfiguration: AuthUIConfiguration? = null, + onComplete: () -> Unit, + onSkip: () -> Unit = {}, + onError: (Exception) -> Unit = {}, + content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null +) { + val activity = requireNotNull(LocalActivity.current) { + "MfaEnrollmentScreen must be used within an Activity context for SMS verification" + } + val coroutineScope = rememberCoroutineScope() + val applicationContext = LocalContext.current.applicationContext + + val smsHandler = remember(activity, auth, user) { SmsEnrollmentHandler(activity, auth, user) } + val totpHandler = remember(auth, user) { TotpEnrollmentHandler(auth, user) } + + val currentStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) } + val selectedFactor = rememberSaveable { mutableStateOf(null) } + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(null) } + val lastException = remember { mutableStateOf(null) } + val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } + + val phoneNumber = rememberSaveable { mutableStateOf("") } + val selectedCountry = remember { mutableStateOf(CountryUtils.getDefaultCountry()) } + val smsSession = remember { mutableStateOf(null) } + + val totpSecret = remember { mutableStateOf(null) } + val totpQrCodeUrl = remember { mutableStateOf(null) } + + val verificationCode = rememberSaveable { mutableStateOf("") } + + val recoveryCodes = remember { mutableStateOf?>(null) } + + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + + val phoneAuthConfiguration = remember(authConfiguration, applicationContext) { + authConfiguration ?: authUIConfiguration { + context = applicationContext + providers { + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + } + } + } + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + LaunchedEffect(Unit) { + if (configuration.allowedFactors.size == 1) { + selectedFactor.value = configuration.allowedFactors.first() + when (selectedFactor.value) { + MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> { + currentStep.value = MfaEnrollmentStep.ConfigureTotp + isLoading.value = true + try { + val secret = totpHandler.generateSecret() + totpSecret.value = secret + totpQrCodeUrl.value = secret.generateQrCodeUrl( + accountName = user.email ?: user.phoneNumber ?: "User", + issuer = auth.app.name + ) + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + null -> {} + } + } + } + + val state = MfaEnrollmentContentState( + step = currentStep.value, + isLoading = isLoading.value, + error = error.value, + exception = lastException.value, + onBackClick = { + when (currentStep.value) { + MfaEnrollmentStep.SelectFactor -> {} + MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> { + currentStep.value = MfaEnrollmentStep.SelectFactor + selectedFactor.value = null + phoneNumber.value = "" + totpSecret.value = null + totpQrCodeUrl.value = null + } + MfaEnrollmentStep.VerifyFactor -> { + verificationCode.value = "" + when (selectedFactor.value) { + MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms + MfaFactor.Totp -> currentStep.value = MfaEnrollmentStep.ConfigureTotp + null -> currentStep.value = MfaEnrollmentStep.SelectFactor + } + } + MfaEnrollmentStep.ShowRecoveryCodes -> { + currentStep.value = MfaEnrollmentStep.VerifyFactor + } + } + error.value = null + lastException.value = null + }, + availableFactors = configuration.allowedFactors, + enrolledFactors = enrolledFactors.value, + onFactorSelected = { factor -> + selectedFactor.value = factor + when (factor) { + MfaFactor.Sms -> { + currentStep.value = MfaEnrollmentStep.ConfigureSms + } + MfaFactor.Totp -> { + currentStep.value = MfaEnrollmentStep.ConfigureTotp + coroutineScope.launch { + isLoading.value = true + try { + val secret = totpHandler.generateSecret() + totpSecret.value = secret + totpQrCodeUrl.value = secret.generateQrCodeUrl( + accountName = user.email ?: user.phoneNumber ?: "User", + issuer = auth.app.name + ) + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + } + } + }, + onUnenrollFactor = { factorInfo -> + coroutineScope.launch { + isLoading.value = true + try { + user.multiFactor.unenroll(factorInfo).addOnCompleteListener { task -> + if (task.isSuccessful) { + // Refresh the enrolled factors list + enrolledFactors.value = user.multiFactor.enrolledFactors + error.value = null + } else { + error.value = task.exception?.message + task.exception?.let { + lastException.value = it + onError(it) + } + } + isLoading.value = false + } + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + isLoading.value = false + } + } + }, + onSkipClick = if (!configuration.requireEnrollment) { + { onSkip() } + } else null, + phoneNumber = phoneNumber.value, + onPhoneNumberChange = { phone -> + phoneNumber.value = phone + error.value = null + }, + selectedCountry = selectedCountry.value, + onCountrySelected = { country -> + selectedCountry.value = country + }, + onSendSmsCodeClick = { + coroutineScope.launch { + isLoading.value = true + try { + val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}" + val session = smsHandler.sendVerificationCode(fullPhoneNumber) + smsSession.value = session + currentStep.value = MfaEnrollmentStep.VerifyFactor + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + }, + totpSecret = totpSecret.value, + totpQrCodeUrl = totpQrCodeUrl.value, + onContinueToVerifyClick = { + currentStep.value = MfaEnrollmentStep.VerifyFactor + }, + verificationCode = verificationCode.value, + onVerificationCodeChange = { code -> + verificationCode.value = code + error.value = null + }, + onVerifyClick = { + coroutineScope.launch { + isLoading.value = true + try { + when (selectedFactor.value) { + MfaFactor.Sms -> { + val session = smsSession.value + if (session != null) { + smsHandler.enrollWithVerificationCode( + session = session, + verificationCode = verificationCode.value, + displayName = "SMS" + ) + } else { + throw IllegalStateException("No SMS session available") + } + } + MfaFactor.Totp -> { + val secret = totpSecret.value + if (secret != null) { + totpHandler.enrollWithVerificationCode( + totpSecret = secret, + verificationCode = verificationCode.value, + displayName = "Authenticator App" + ) + } else { + throw IllegalStateException("No TOTP secret available") + } + } + null -> throw IllegalStateException("No factor selected") + } + + // Refresh enrolled factors after successful enrollment + enrolledFactors.value = user.multiFactor.enrolledFactors + + if (configuration.enableRecoveryCodes) { + recoveryCodes.value = generateRecoveryCodes() + currentStep.value = MfaEnrollmentStep.ShowRecoveryCodes + } else { + onComplete() + } + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + }, + selectedFactor = selectedFactor.value, + resendTimer = resendTimerSeconds.intValue, + onResendCodeClick = if (selectedFactor.value == MfaFactor.Sms) { + { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + val session = smsSession.value + if (session != null) { + isLoading.value = true + try { + val newSession = smsHandler.resendVerificationCode(session) + smsSession.value = newSession + resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS + error.value = null + lastException.value = null + } catch (e: Exception) { + error.value = e.message + lastException.value = e + onError(e) + } finally { + isLoading.value = false + } + } + } + } + } + } else null, + recoveryCodes = recoveryCodes.value, + onCodesSavedClick = { + onComplete() + } + ) + + if (content != null) { + content(state) + } else { + DefaultMfaEnrollmentContent( + state = state, + authConfiguration = phoneAuthConfiguration, + user = user + ) + } +} + +/** + * Generates placeholder recovery codes. + * In a production implementation, these would come from Firebase or a backend service. + */ +private fun generateRecoveryCodes(): List { + return List(10) { index -> + List(4) { (0..9).random() } + .joinToString("") + .let { if (index % 2 == 0) "$it-${(1000..9999).random()}" else it } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt new file mode 100644 index 0000000000..2ebc2542ff --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt @@ -0,0 +1,424 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.createOrLinkUserWithEmailAndPassword +import com.firebase.ui.auth.configuration.auth_provider.sendPasswordResetEmail +import com.firebase.ui.auth.configuration.auth_provider.sendSignInLinkToEmail +import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailAndPassword +import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler +import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.AuthResult +import kotlinx.coroutines.launch + +enum class EmailAuthMode { + SignIn, + EmailLinkSignIn, + SignUp, + ResetPassword, +} + +/** + * A class passed to the content slot, containing all the necessary information to render custom + * UIs for sign-in, sign-up, and password reset flows. + * + * @param mode An enum representing the current UI mode. Use a when expression on this to render + * the correct screen. + * @param isLoading true when an asynchronous operation (like signing in or sending an email) + * is in progress. + * @param error An optional error message to display to the user. + * @param email The current value of the email input field. + * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp], + * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes. + * @param password An optional custom layout composable for the provider buttons. + * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current + * value of the password input field. + * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password + * input changes. + * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when + * the password confirmation input changes. + * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field. + * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the + * display name input changes. + * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a + * sign-in with the provided credentials. + * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to + * create a new account. + * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to + * send a password reset email. + * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link + * has been successfully sent. + * @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has + * been successfully sent. + * @param onGoToSignUp A callback to switch the UI to the SignUp mode. + * @param onGoToSignIn A callback to switch the UI to the SignIn mode. + * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode. + */ +class EmailAuthContentState( + val mode: EmailAuthMode, + val isLoading: Boolean = false, + val error: String? = null, + val email: String, + val onEmailChange: (String) -> Unit, + val password: String, + val onPasswordChange: (String) -> Unit, + val confirmPassword: String, + val onConfirmPasswordChange: (String) -> Unit, + val displayName: String, + val onDisplayNameChange: (String) -> Unit, + val onRetrievedCredential: (Pair) -> Unit, + val onSignInClick: () -> Unit, + val onSignInEmailLinkClick: () -> Unit, + val onSignUpClick: () -> Unit, + val onSendResetLinkClick: () -> Unit, + val resetLinkSent: Boolean = false, + val emailSignInLinkSent: Boolean = false, + val onGoToSignUp: () -> Unit, + val onGoToSignIn: () -> Unit, + val onGoToResetPassword: () -> Unit, + val onGoToEmailLinkSignIn: () -> Unit, +) + +/** + * A stateful composable that manages the logic for all email-based authentication flows, + * including sign-in, sign-up, and password reset. It exposes the state for the current mode to + * a custom UI via a trailing lambda (slot), allowing for complete visual customization. + * + * @param configuration + * @param onSuccess + * @param onError + * @param onCancel + * @param content + */ +@Composable +fun EmailAuthScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + credentialForLinking: AuthCredential? = null, + emailLinkFromDifferentDevice: String? = null, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + content: @Composable ((EmailAuthContentState) -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current + val coroutineScope = rememberCoroutineScope() + + // Start in EmailLinkSignIn mode if coming from cross-device flow + val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) { + EmailAuthMode.EmailLinkSignIn + } else { + EmailAuthMode.SignIn + } + val mode = rememberSaveable { mutableStateOf(initialMode) } + val displayNameValue = rememberSaveable { mutableStateOf("") } + val emailTextValue = rememberSaveable { mutableStateOf("") } + val passwordTextValue = rememberSaveable { mutableStateOf("") } + val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") } + + // Used for clearing text fields when switching EmailAuthMode changes + val textValues = listOf( + displayNameValue, + emailTextValue, + passwordTextValue, + confirmPasswordTextValue + ) + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val isLoading = authState is AuthState.Loading + val authCredentialForLinking = remember { credentialForLinking } + val errorMessage = + if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + val resetLinkSent = authState is AuthState.PasswordResetLinkSent + val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent + + // Track if credentials were retrieved from Credential Manager + val retrievedCredential = remember { mutableStateOf?>(null) } + + LaunchedEffect(authState) { + Log.d("EmailAuthScreen", "Current state: $authState") + when (val state = authState) { + is AuthState.Success -> { + state.result?.let { result -> + onSuccess(result) + } + } + + is AuthState.Error -> { + val exception = AuthException.from(state.exception) + onError(exception) + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.UserNotFoundException -> { + val provider = configuration.providers + .filterIsInstance() + .first() + if (provider.isNewAccountsAllowed) { + // User not found, but new accounts are allowed, switch to sign-up + mode.value = EmailAuthMode.SignUp + } + } + + is AuthException.InvalidCredentialsException -> { + // User can retry sign in with corrected credentials + } + + is AuthException.EmailAlreadyInUseException -> { + // Switch to sign-in mode + mode.value = EmailAuthMode.SignIn + } + + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + + is AuthState.Cancelled -> { + onCancel() + } + + else -> Unit + } + } + + val state = EmailAuthContentState( + mode = mode.value, + displayName = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + confirmPassword = confirmPasswordTextValue.value, + isLoading = isLoading, + error = errorMessage, + resetLinkSent = resetLinkSent, + emailSignInLinkSent = emailSignInLinkSent, + onEmailChange = { email -> + emailTextValue.value = email + }, + onPasswordChange = { password -> + passwordTextValue.value = password + }, + onConfirmPasswordChange = { confirmPassword -> + confirmPasswordTextValue.value = confirmPassword + }, + onDisplayNameChange = { displayName -> + displayNameValue.value = displayName + }, + onRetrievedCredential = { credential -> + retrievedCredential.value = credential + }, + onSignInClick = { + coroutineScope.launch { + try { + // Check if user is signing in with retrieved credentials + val isUsingRetrievedCredential = retrievedCredential.value?.let { (email, password) -> + email == emailTextValue.value && password == passwordTextValue.value + } ?: false + + authUI.signInWithEmailAndPassword( + context = context, + config = configuration, + email = emailTextValue.value, + password = passwordTextValue.value, + credentialForLinking = authCredentialForLinking, + skipCredentialSave = isUsingRetrievedCredential + ) + } catch (e: Exception) { + onError(AuthException.from(e)) + } + } + }, + onSignInEmailLinkClick = { + coroutineScope.launch { + try { + if (emailLinkFromDifferentDevice != null) { + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + emailLink = emailLinkFromDifferentDevice, + ) + } else { + authUI.sendSignInLinkToEmail( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + credentialForLinking = authCredentialForLinking, + ) + } + } catch (e: Exception) { + onError(AuthException.from(e)) + } + } + }, + onSignUpClick = { + coroutineScope.launch { + try { + authUI.createOrLinkUserWithEmailAndPassword( + context = context, + config = configuration, + provider = provider, + name = displayNameValue.value, + email = emailTextValue.value, + password = passwordTextValue.value, + ) + } catch (e: Exception) { + + } + } + }, + onSendResetLinkClick = { + coroutineScope.launch { + try { + authUI.sendPasswordResetEmail( + email = emailTextValue.value, + actionCodeSettings = configuration.passwordResetActionCodeSettings, + ) + } catch (e: Exception) { + + } + } + }, + onGoToSignUp = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignUp + }, + onGoToSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.SignIn + }, + onGoToResetPassword = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.ResetPassword + }, + onGoToEmailLinkSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.EmailLinkSignIn + }, + ) + + if (content != null) { + content(state) + } else { + DefaultEmailAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } +} + +@Composable +private fun DefaultEmailAuthContent( + configuration: AuthUIConfiguration, + state: EmailAuthContentState, + onCancel: () -> Unit, +) { + when (state.mode) { + EmailAuthMode.SignIn -> { + SignInUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + password = state.password, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onRetrievedCredential = state.onRetrievedCredential, + onSignInClick = state.onSignInClick, + onGoToSignUp = state.onGoToSignUp, + onGoToResetPassword = state.onGoToResetPassword, + onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.EmailLinkSignIn -> { + SignInEmailLinkUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + onEmailChange = state.onEmailChange, + onSignInWithEmailLink = state.onSignInEmailLinkClick, + onGoToSignIn = state.onGoToSignIn, + onGoToResetPassword = state.onGoToResetPassword, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.SignUp -> { + SignUpUI( + configuration = configuration, + isLoading = state.isLoading, + displayName = state.displayName, + email = state.email, + password = state.password, + confirmPassword = state.confirmPassword, + onDisplayNameChange = state.onDisplayNameChange, + onEmailChange = state.onEmailChange, + onPasswordChange = state.onPasswordChange, + onConfirmPasswordChange = state.onConfirmPasswordChange, + onSignUpClick = state.onSignUpClick, + onGoToSignIn = state.onGoToSignIn, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.ResetPassword -> { + ResetPasswordUI( + configuration = configuration, + isLoading = state.isLoading, + email = state.email, + resetLinkSent = state.resetLinkSent, + onEmailChange = state.onEmailChange, + onSendResetLink = state.onSendResetLinkClick, + onGoToSignIn = state.onGoToSignIn, + onNavigateBack = onCancel + ) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt new file mode 100644 index 0000000000..8b73f2c2d0 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt @@ -0,0 +1,225 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.ui.components.AuthTextField +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResetPasswordUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + email: String, + resetLinkSent: Boolean, + onEmailChange: (String) -> Unit, + onSendResetLink: () -> Unit, + onGoToSignIn: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { + EmailValidator(stringProvider) + } + + val isFormValid = remember(email) { + derivedStateOf { emailValidator.validate(email) } + } + + val isDialogVisible = remember(resetLinkSent) { mutableStateOf(resetLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = stringProvider.recoverPasswordLinkSentDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringProvider.recoverPasswordLinkSentDialogBody(email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + onGoToSignIn() + isDialogVisible.value = false + } + ) { + Text(stringProvider.dismissAction) + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(stringProvider.recoverPasswordPageTitle) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signInDefault.uppercase()) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSendResetLink() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.sendButtonText.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewResetPasswordUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + ResetPasswordUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + resetLinkSent = true, + onEmailChange = { email -> }, + onSendResetLink = {}, + onGoToSignIn = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt new file mode 100644 index 0000000000..14e0458279 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.ui.components.AuthTextField +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.google.firebase.auth.actionCodeSettings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInEmailLinkUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + onEmailChange: (String) -> Unit, + onSignInWithEmailLink: () -> Unit, + onGoToSignIn: () -> Unit, + onGoToResetPassword: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { EmailValidator(stringProvider) } + + val isFormValid = remember(email) { + derivedStateOf { + emailValidator.validate(email) + } + } + + if (provider.isEmailLinkSignInEnabled) { + val isDialogVisible = + remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = stringProvider.emailSignInLinkSentDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringProvider.emailSignInLinkSentDialogBody(email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + isDialogVisible.value = false + } + ) { + Text(stringProvider.dismissAction) + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringProvider.signInDefault, + modifier = Modifier.semantics { heading() } + ) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + onSignInWithEmailLink() + }, + modifier = Modifier.align(Alignment.End), + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp) + ) + } else { + Text(stringProvider.signInDefault.uppercase()) + } + } + + // Show toggle to go back to password mode + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringProvider.orContinueWith, + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onGoToSignIn() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text(stringProvider.signInWithPassword.uppercase()) + } + + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInEmailLinkUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = true, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://fake-project-id.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "fake.project.id", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInEmailLinkUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onSignInWithEmailLink = {}, + onGoToSignIn = {}, + onGoToResetPassword = {}, + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt new file mode 100644 index 0000000000..eabde87a16 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt @@ -0,0 +1,348 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.configuration.validators.PasswordValidator +import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialException +import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler +import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException +import com.firebase.ui.auth.ui.components.AuthTextField +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onRetrievedCredential: (Pair) -> Unit, + onSignInClick: () -> Unit, + onGoToSignUp: () -> Unit, + onGoToResetPassword: () -> Unit, + onGoToEmailLinkSignIn: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator(stringProvider = stringProvider, rules = emptyList()) + } + + val isFormValid = remember(email, password) { + derivedStateOf { + listOf( + emailValidator.validate(email), + passwordValidator.validate(password) + ).all { it } + } + } + + // Retrieve saved credentials when in SignIn mode + val credentialRetrievalAttempted = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (configuration.isCredentialManagerEnabled && + !credentialRetrievalAttempted.value && + PasswordCredentialHandler.hasSavedCredentials(context)) { + credentialRetrievalAttempted.value = true + + try { + val credentialHandler = PasswordCredentialHandler(context) + val credential = credentialHandler.getPassword() + + Log.d("EmailAuthScreen", "Retrieved credential for: ${credential.username}") + + // Auto-fill the email and password fields + onEmailChange(credential.username) + onPasswordChange(credential.password) + + emailValidator.validate(credential.username) + passwordValidator.validate(credential.password) + + // Store retrieved credential to compare later + onRetrievedCredential(Pair(credential.username, credential.password)) + + onSignInClick() + } catch (e: PasswordCredentialNotFoundException) { + Log.d("EmailAuthScreen", "No saved credentials found") + // No credentials saved - user will enter manually + } catch (e: PasswordCredentialCancelledException) { + Log.d("EmailAuthScreen", "User cancelled credential selection") + // User cancelled - let them enter manually + } catch (e: PasswordCredentialException) { + Log.w("EmailAuthScreen", "Failed to retrieve credentials", e) + // Failed to retrieve - let them enter manually + } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringProvider.signInDefault, + modifier = Modifier.semantics { heading() } + ) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { + PlainTooltip { + Text(stringProvider.newAccountsDisabledTooltip) + } + }, + state = rememberTooltipState( + initialIsVisible = !provider.isNewAccountsAllowed + ) + ) { + Button( + onClick = { + onGoToSignUp() + }, + enabled = provider.isNewAccountsAllowed && !isLoading, + ) { + Text(stringProvider.signupPageTitle.uppercase()) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSignInClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signInDefault.uppercase()) + } + } + } + + // Show toggle to email link sign-in + if (provider.isEmailLinkSignInEnabled) { + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringProvider.orContinueWith, + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onGoToEmailLinkSignIn() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text(stringProvider.signInWithEmailLink.uppercase()) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = false, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + val stringProvider = DefaultAuthUIStringProvider(applicationContext) + + AuthUITheme { + CompositionLocalProvider( + LocalAuthUIStringProvider provides stringProvider + ) { + SignInUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + password = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onRetrievedCredential = { credential -> }, + onSignInClick = {}, + onGoToSignUp = {}, + onGoToResetPassword = {}, + onGoToEmailLinkSignIn = {}, + ) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt new file mode 100644 index 0000000000..dfc413bc6a --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.email + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.EmailValidator +import com.firebase.ui.auth.configuration.validators.GeneralFieldValidator +import com.firebase.ui.auth.configuration.validators.PasswordValidator +import com.firebase.ui.auth.ui.components.AuthTextField +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignUpUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + displayName: String, + email: String, + password: String, + confirmPassword: String, + onDisplayNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onGoToSignIn: () -> Unit, + onSignUpClick: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val displayNameValidator = remember { GeneralFieldValidator(stringProvider) } + val emailValidator = remember { EmailValidator(stringProvider) } + val passwordValidator = remember { + PasswordValidator( + stringProvider = stringProvider, + rules = provider.passwordValidationRules + ) + } + val confirmPasswordValidator = remember(password) { + GeneralFieldValidator( + stringProvider = stringProvider, + isValid = { value -> + value == password + }, + customMessage = stringProvider.passwordsDoNotMatch + ) + } + + val isFormValid = remember(displayName, email, password, confirmPassword) { + derivedStateOf { + listOf( + displayNameValidator.validate(displayName), + emailValidator.validate(email), + passwordValidator.validate(password), + confirmPasswordValidator.validate(confirmPassword) + ).all { it } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(stringProvider.signupPageTitle) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + if (provider.isDisplayNameRequired) { + AuthTextField( + value = displayName, + validator = displayNameValidator, + enabled = !isLoading, + label = { + Text(stringProvider.nameHint) + }, + onValueChange = { text -> + onDisplayNameChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = confirmPassword, + validator = confirmPasswordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.confirmPasswordHint) + }, + onValueChange = { text -> + onConfirmPasswordChange(text) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = { + onGoToSignIn() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signInDefault.uppercase()) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onSignUpClick() + }, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.signupPageTitle.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignUpUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = false, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = null, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignUpUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + displayName = "", + email = "", + password = "", + confirmPassword = "", + onDisplayNameChange = { name -> }, + onEmailChange = { email -> }, + onPasswordChange = { password -> }, + onConfirmPasswordChange = { confirmPassword -> }, + onSignUpClick = {}, + onGoToSignIn = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt new file mode 100644 index 0000000000..ef8bfe5454 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.phone + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.PhoneNumberValidator +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.components.AuthTextField +import com.firebase.ui.auth.ui.components.CountrySelector +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.util.CountryUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnterPhoneNumberUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + phoneNumber: String, + selectedCountry: CountryData, + onPhoneNumberChange: (String) -> Unit, + onCountrySelected: (CountryData) -> Unit, + onSendCodeClick: () -> Unit, + title: String? = null, + onNavigateBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val phoneNumberValidator = remember(selectedCountry) { + PhoneNumberValidator(stringProvider, selectedCountry) + } + + val isFormValid = remember(selectedCountry, phoneNumber) { + derivedStateOf { + phoneNumberValidator.validate(phoneNumber) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(title ?: stringProvider.signInWithPhone) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text(stringProvider.enterPhoneNumberTitle) + Spacer(modifier = Modifier.height(16.dp)) + AuthTextField( + value = phoneNumber, + validator = phoneNumberValidator, + enabled = !isLoading, + label = { + Text(stringProvider.phoneNumberHint) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone + ), + leadingIcon = { + CountrySelector( + selectedCountry = selectedCountry, + onCountrySelected = onCountrySelected, + enabled = !isLoading, + allowedCountries = provider.allowedCountries?.toSet() + ) + }, + onValueChange = { + onPhoneNumberChange(it) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = onSendCodeClick, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.sendVerificationCode.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewEnterPhoneNumberUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + AuthUITheme { + EnterPhoneNumberUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + phoneNumber = "", + selectedCountry = CountryUtils.getDefaultCountry(), + onPhoneNumberChange = {}, + onCountrySelected = {}, + onSendCodeClick = {}, + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt new file mode 100644 index 0000000000..122e73a87a --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.phone + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnterVerificationCodeUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + verificationCode: String, + fullPhoneNumber: String, + resendTimer: Int, + onVerificationCodeChange: (String) -> Unit, + onVerifyCodeClick: () -> Unit, + onResendCodeClick: () -> Unit, + onChangeNumberClick: () -> Unit, + title: String? = null, + onNavigateBack: (() -> Unit)? = null, +) { + val context = LocalContext.current + val stringProvider = LocalAuthUIStringProvider.current + val verificationCodeValidator = remember { + VerificationCodeValidator(stringProvider) + } + + val isFormValid = remember(verificationCode) { + derivedStateOf { + verificationCodeValidator.validate(verificationCode) + } + } + + val resendEnabled = resendTimer == 0 && !isLoading + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(title ?: stringProvider.verifyPhoneNumber) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringProvider.enterVerificationCodeTitle(fullPhoneNumber), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + modifier = Modifier.align(Alignment.Start), + onClick = onChangeNumberClick, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + text = stringProvider.changePhoneNumber, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + VerificationCodeInputField( + modifier = Modifier.align(Alignment.CenterHorizontally), + validator = verificationCodeValidator, + onCodeChange = onVerificationCodeChange + ) + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + modifier = Modifier.align(Alignment.Start), + onClick = onResendCodeClick, + enabled = resendEnabled, + contentPadding = PaddingValues.Zero + ) { + Text( + text = if (resendTimer > 0) { + val minutes = resendTimer / 60 + val seconds = resendTimer % 60 + val timeFormatted = + "$minutes:${String.format(Locale.ROOT, "%02d", seconds)}" + stringProvider.resendCodeTimer(timeFormatted) + } else { + stringProvider.resendCode + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = if (resendEnabled) TextDecoration.Underline else TextDecoration.None + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .align(Alignment.End), + ) { + Button( + onClick = onVerifyCodeClick, + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + ) + } else { + Text(stringProvider.verifyPhoneNumber.uppercase()) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewEnterVerificationCodeUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null, + timeout = 60L, + isInstantVerificationEnabled = true + ) + + AuthUITheme { + EnterVerificationCodeUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + isLoading = false, + verificationCode = "", + fullPhoneNumber = "+1234567890", + resendTimer = 30, + onVerificationCodeChange = {}, + onVerifyCodeClick = {}, + onResendCodeClick = {}, + onChangeNumberClick = {}, + ) + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt new file mode 100644 index 0000000000..fa6278976d --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -0,0 +1,387 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.ui.screens.phone + +import android.content.Context +import android.util.Log +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.signInWithPhoneAuthCredential +import com.firebase.ui.auth.configuration.auth_provider.submitVerificationCode +import com.firebase.ui.auth.configuration.auth_provider.verifyPhoneNumber +import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.util.CountryUtils +import com.google.firebase.auth.AuthResult +import com.google.firebase.auth.PhoneAuthProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +enum class PhoneAuthStep { + /** + * An enum representing a view requiring a phone number which needs to be entered. + */ + EnterPhoneNumber, + + /** + * An enum representing a view requiring a phone number verification code which needs to + * be entered. + */ + EnterVerificationCode +} + +/** + * A class passed to the content slot, containing all the necessary information to render a custom + * UI for every step of the phone authentication process. + * + * @param step An enum representing the current step in the flow. Use a when expression on this + * to render the correct UI. + * @param isLoading true when an asynchronous operation (like sending or verifying a code) is in + * progress. + * @param error An optional error message to display to the user. + * @param phoneNumber (Step: [PhoneAuthStep.EnterPhoneNumber]) The current value of the phone + * number input field. + * @param onPhoneNumberChange (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked + * when the phone number input changes. + * @param selectedCountry (Step: [PhoneAuthStep.EnterPhoneNumber]) The currently selected country + * object, containing its name, dial code, and flag. + * @param onCountrySelected (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked when + * the user selects a new country. + * @param onSendCodeClick (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked to + * send the verification code to the entered number. + * @param verificationCode (Step: [PhoneAuthStep.EnterVerificationCode]) The current value of the + * 6-digit code input field. + * @param onVerificationCodeChange (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be + * invoked when the verification code input changes. + * @param onVerifyCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked + * to submit the verification code. + * @param fullPhoneNumber (Step: [PhoneAuthStep.EnterVerificationCode]) The formatted full phone + * number to display for user confirmation. + * @param onResendCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked + * when the user clicks "Resend Code". + * @param resendTimer (Step: [PhoneAuthStep.EnterVerificationCode]) The number of seconds remaining + * before the "Resend" action is available. + * @param onChangeNumberClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to navigate + * back to the [PhoneAuthStep.EnterPhoneNumber] step. + */ +class PhoneAuthContentState( + val step: PhoneAuthStep, + val isLoading: Boolean = false, + val error: String? = null, + val phoneNumber: String, + val onPhoneNumberChange: (String) -> Unit, + val selectedCountry: CountryData, + val onCountrySelected: (CountryData) -> Unit, + val onSendCodeClick: () -> Unit, + val verificationCode: String, + val onVerificationCodeChange: (String) -> Unit, + val onVerifyCodeClick: () -> Unit, + val fullPhoneNumber: String, + val onResendCodeClick: () -> Unit, + val resendTimer: Int = 0, + val onChangeNumberClick: () -> Unit, +) + +/** + * A stateful composable that manages the complete logic for phone number authentication. It handles + * the multi-step flow of sending and verifying an SMS code, exposing the state for each step to a + * custom UI via a trailing lambda (slot). This component renders no UI itself. + * + * @param context The Android context. + * @param configuration The authentication UI configuration containing the phone provider settings. + * @param authUI The FirebaseAuthUI instance used for authentication operations. + * @param onSuccess Callback invoked when authentication succeeds with the [AuthResult]. + * @param onError Callback invoked when an authentication error occurs. + * @param onCancel Callback invoked when the user cancels the authentication flow. + * @param modifier Optional [Modifier] for the composable. + * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for + * each step. If null, no UI will be rendered. + */ +@Composable +fun PhoneAuthScreen( + context: Context, + configuration: AuthUIConfiguration, + authUI: FirebaseAuthUI, + onSuccess: (AuthResult) -> Unit, + onError: (AuthException) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ((PhoneAuthContentState) -> Unit)? = null, +) { + val activity = LocalActivity.current + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current + val coroutineScope = rememberCoroutineScope() + + val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } + val phoneNumberValue = rememberSaveable { mutableStateOf(provider.defaultNumber ?: "") } + val verificationCodeValue = rememberSaveable { mutableStateOf("") } + val selectedCountry = remember { + mutableStateOf( + provider.defaultCountryCode?.let { code -> + CountryUtils.findByCountryCode(code) + } ?: CountryUtils.getDefaultCountry() + ) + } + val fullPhoneNumber = remember(selectedCountry.value, phoneNumberValue.value) { + CountryUtils.formatPhoneNumber(selectedCountry.value.dialCode, phoneNumberValue.value) + } + val verificationId = rememberSaveable { mutableStateOf(null) } + val forceResendingToken = + rememberSaveable { mutableStateOf(null) } + val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } + val pendingVerificationPhoneNumber = remember { mutableStateOf(null) } + val verificationStartTime = remember { mutableStateOf(null) } + + val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val isLoading = authState is AuthState.Loading + val errorMessage = + if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null + + // Handle resend timer countdown + LaunchedEffect(resendTimerSeconds.intValue) { + if (resendTimerSeconds.intValue > 0) { + delay(1000) + resendTimerSeconds.intValue-- + } + } + + LaunchedEffect(authState) { + Log.d("PhoneAuthScreen", "Current state: $authState") + when (val state = authState) { + is AuthState.Success -> { + state.result?.let { result -> + onSuccess(result) + } + } + + is AuthState.PhoneNumberVerificationRequired -> { + verificationId.value = state.verificationId + forceResendingToken.value = state.forceResendingToken + step.value = PhoneAuthStep.EnterVerificationCode + resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown + } + + is AuthState.SMSAutoVerified -> { + // Auto-verification succeeded, sign in with the credential + // and clear pending verification tracking + pendingVerificationPhoneNumber.value = null + verificationStartTime.value = null + + coroutineScope.launch { + try { + authUI.signInWithPhoneAuthCredential( + context = context, + config = configuration, + credential = state.credential + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + } + + is AuthState.Error -> { + val exception = AuthException.from(state.exception) + onError(exception) + + // Show dialog for phone-specific errors using top-level controller + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry with corrected code or phone number + } + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } + + is AuthState.Cancelled -> { + onCancel() + } + + else -> Unit + } + } + + val state = PhoneAuthContentState( + step = step.value, + isLoading = isLoading, + error = errorMessage, + phoneNumber = phoneNumberValue.value, + onPhoneNumberChange = { number -> + phoneNumberValue.value = number + }, + selectedCountry = selectedCountry.value, + onCountrySelected = { country -> + selectedCountry.value = country + }, + onSendCodeClick = { + coroutineScope.launch { + try { + val currentTime = System.currentTimeMillis() + val timeoutMs = provider.timeout * 1000 + val timeSinceLastVerification = verificationStartTime.value?.let { + currentTime - it + } ?: Long.MAX_VALUE + + // Check if the same phone number is being verified again within the cooldown period + val storedNumber = pendingVerificationPhoneNumber.value + val isSameNumber = storedNumber != null && fullPhoneNumber == storedNumber + + // Check cooldown: same number and still within timeout period + if (isSameNumber && timeSinceLastVerification < timeoutMs) { + // Calculate remaining cooldown time in seconds + val remainingCooldownSeconds = ((timeoutMs - timeSinceLastVerification) / 1000).coerceAtLeast(1) + val cooldownException = AuthException.PhoneVerificationCooldownException( + message = "Please wait ${remainingCooldownSeconds} second${if (remainingCooldownSeconds != 1L) "s" else ""} before verifying the same phone number again. The cooldown period is ${provider.timeout} seconds.", + cooldownSeconds = remainingCooldownSeconds + ) + // Update auth state to show the error + authUI.updateAuthState(AuthState.Error(cooldownException)) + throw cooldownException + } + + // Track the phone number and start time for cooldown checking + pendingVerificationPhoneNumber.value = fullPhoneNumber + verificationStartTime.value = currentTime + + authUI.verifyPhoneNumber( + provider = provider, + activity = activity, + phoneNumber = fullPhoneNumber, + ) + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + }, + verificationCode = verificationCodeValue.value, + onVerificationCodeChange = { code -> + verificationCodeValue.value = code + }, + onVerifyCodeClick = { + coroutineScope.launch { + try { + verificationId.value?.let { id -> + authUI.submitVerificationCode( + context = context, + config = configuration, + verificationId = id, + code = verificationCodeValue.value + ) + } + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + }, + fullPhoneNumber = fullPhoneNumber, + onResendCodeClick = { + if (resendTimerSeconds.intValue == 0) { + coroutineScope.launch { + try { + authUI.verifyPhoneNumber( + activity = activity, + provider = provider, + phoneNumber = fullPhoneNumber, + forceResendingToken = forceResendingToken.value, + ) + resendTimerSeconds.intValue = provider.timeout.toInt() // Restart timer + } catch (e: Exception) { + // Error will be handled by authState flow + } + } + } + }, + resendTimer = resendTimerSeconds.intValue, + onChangeNumberClick = { + step.value = PhoneAuthStep.EnterPhoneNumber + verificationCodeValue.value = "" + verificationId.value = null + forceResendingToken.value = null + resendTimerSeconds.intValue = 0 + } + ) + + if (content != null) { + content(state) + } else { + DefaultPhoneAuthContent( + configuration = configuration, + state = state, + onCancel = onCancel + ) + } +} + +@Composable +private fun DefaultPhoneAuthContent( + configuration: AuthUIConfiguration, + state: PhoneAuthContentState, + onCancel: () -> Unit, +) { + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> { + EnterPhoneNumberUI( + configuration = configuration, + isLoading = state.isLoading, + phoneNumber = state.phoneNumber, + selectedCountry = state.selectedCountry, + onPhoneNumberChange = state.onPhoneNumberChange, + onCountrySelected = state.onCountrySelected, + onSendCodeClick = state.onSendCodeClick, + onNavigateBack = onCancel + ) + } + + PhoneAuthStep.EnterVerificationCode -> { + EnterVerificationCodeUI( + configuration = configuration, + isLoading = state.isLoading, + verificationCode = state.verificationCode, + fullPhoneNumber = state.fullPhoneNumber, + resendTimer = state.resendTimer, + onVerificationCodeChange = state.onVerificationCodeChange, + onVerifyCodeClick = state.onVerifyCodeClick, + onResendCodeClick = state.onResendCodeClick, + onChangeNumberClick = state.onChangeNumberClick, + onNavigateBack = onCancel + ) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt new file mode 100644 index 0000000000..80efbd8bd4 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.firebase.ui.auth.util + +import androidx.annotation.RestrictTo +import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.ANONYMOUS_USER_ID_IDENTIFIER +import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.FORCE_SAME_DEVICE_IDENTIFIER +import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.PROVIDER_ID_IDENTIFIER +import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.SESSION_IDENTIFIER + +/** + * Builder for constructing continue URLs with embedded session and authentication parameters. + * Used in email link sign-in flows to pass state between devices. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class ContinueUrlBuilder(url: String) { + + private val continueUrl: StringBuilder + + init { + require(url.isNotBlank()) { "URL cannot be empty" } + continueUrl = StringBuilder(url).append("?") + } + + fun appendSessionId(sessionId: String): ContinueUrlBuilder { + addQueryParam(SESSION_IDENTIFIER, sessionId) + return this + } + + fun appendAnonymousUserId(anonymousUserId: String): ContinueUrlBuilder { + addQueryParam(ANONYMOUS_USER_ID_IDENTIFIER, anonymousUserId) + return this + } + + fun appendProviderId(providerId: String): ContinueUrlBuilder { + addQueryParam(PROVIDER_ID_IDENTIFIER, providerId) + return this + } + + fun appendForceSameDeviceBit(forceSameDevice: Boolean): ContinueUrlBuilder { + val bit = if (forceSameDevice) "1" else "0" + addQueryParam(FORCE_SAME_DEVICE_IDENTIFIER, bit) + return this + } + + private fun addQueryParam(key: String, value: String) { + if (value.isBlank()) return + + val isFirstParam = continueUrl.last() == '?' + val mark = if (isFirstParam) "" else "&" + continueUrl.append("$mark$key=$value") + } + + fun build(): String { + if (continueUrl.last() == '?') { + // No params added so we remove the '?' + continueUrl.setLength(continueUrl.length - 1) + } + return continueUrl.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt new file mode 100644 index 0000000000..a4a4a3cbee --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt @@ -0,0 +1,143 @@ +package com.firebase.ui.auth.util + +import com.firebase.ui.auth.data.ALL_COUNTRIES +import com.firebase.ui.auth.data.CountryData +import java.text.Normalizer +import java.util.Locale + +/** + * Utility functions for searching and filtering countries. + */ +object CountryUtils { + + // Lazy-initialized maps for fast lookups + private val countryCodeMap: Map by lazy { + ALL_COUNTRIES.associateBy { it.countryCode.uppercase() } + } + + private val dialCodeMap: Map> by lazy { + ALL_COUNTRIES.groupBy { it.dialCode } + } + + /** + * Finds a country by its ISO 3166-1 alpha-2 country code. + * + * @param countryCode The two-letter country code (e.g., "US", "GB"). + * @return The CountryData or null if not found. + */ + fun findByCountryCode(countryCode: String): CountryData? { + return countryCodeMap[countryCode.uppercase()] + } + + /** + * Finds all countries with the given dial code. + * + * @param dialCode The international dialing code (e.g., "+1", "+44"). + * @return List of countries with that dial code, or empty list if none found. + */ + fun findByDialCode(dialCode: String): List { + return dialCodeMap[dialCode] ?: emptyList() + } + + /** + * Searches for countries by name. Supports partial matching and diacritic-insensitive search. + * + * @param query The search query. + * @return List of countries matching the query, or empty list if none found. + */ + fun searchByName(query: String): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + val normalizedQuery = normalizeString(trimmedQuery) + + return ALL_COUNTRIES.filter { country -> + normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) + } + } + + /** + * Searches for countries by name, country code, or dial code. + * Supports partial matching and diacritic-insensitive search. + * + * @param query The search query (country name, country code, or dial code). + * @return List of countries matching the query, sorted by relevance. + */ + fun search(query: String): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + val normalizedQuery = normalizeString(trimmedQuery) + val uppercaseQuery = trimmedQuery.uppercase() + + return ALL_COUNTRIES.filter { country -> + // Match by country name (partial, case-insensitive, diacritic-insensitive) + normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) || + // Match by country code (partial, case-insensitive) + country.countryCode.uppercase().contains(uppercaseQuery) || + // Match by dial code (partial) + country.dialCode.contains(trimmedQuery) + }.sortedWith( + compareBy( + // Prioritize exact matches first + { country -> + when { + country.countryCode.uppercase() == uppercaseQuery -> 0 + country.dialCode == trimmedQuery -> 1 + normalizeString(country.name) == normalizedQuery -> 2 + else -> 3 + } + }, + // Then sort alphabetically by name + { country -> country.name } + ) + ) + } + + /** + * Filters countries by allowed country codes. + * + * @param allowedCountryCodes Set of allowed ISO 3166-1 alpha-2 country codes. + * @return List of countries that are in the allowed set. + */ + fun filterByAllowedCountries(allowedCountryCodes: Set): List { + if (allowedCountryCodes.isEmpty()) return ALL_COUNTRIES + + val uppercaseAllowed = allowedCountryCodes.map { it.uppercase() }.toSet() + return ALL_COUNTRIES.filter { it.countryCode.uppercase() in uppercaseAllowed } + } + + /** + * Gets the default country based on the device's locale. + * + * @return The CountryData for the device's country, or United States as fallback. + */ + fun getDefaultCountry(): CountryData { + val deviceCountryCode = Locale.getDefault().country + return findByCountryCode(deviceCountryCode) ?: findByCountryCode("US")!! + } + + /** + * Formats a phone number with the country's dial code. + * + * @param dialCode The country dial code (e.g., "+1"). + * @param phoneNumber The local phone number. + * @return The formatted international phone number. + */ + fun formatPhoneNumber(dialCode: String, phoneNumber: String): String { + val cleanNumber = phoneNumber.replace(Regex("[^0-9]"), "") + return "$dialCode$cleanNumber" + } + + /** + * Normalizes a string by removing diacritics and converting to lowercase. + * + * @param value The string to normalize. + * @return The normalized string. + */ + private fun normalizeString(value: String): String { + return Normalizer.normalize(value, Normalizer.Form.NFD) + .replace(Regex("\\p{M}"), "") + .lowercase() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt new file mode 100644 index 0000000000..e625344099 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +private val Context.credentialDataStore: DataStore by preferencesDataStore( + name = "com.firebase.ui.auth.util.CredentialPersistenceManager" +) + +/** + * Manages persistence for credential manager state. + * + * This class tracks whether credentials have been saved to the Android Credential Manager + * to prevent unnecessary credential retrieval attempts when no credentials exist. + * + * @since 10.0.0 + */ +object CredentialPersistenceManager { + + private val KEY_HAS_SAVED_CREDENTIALS = booleanPreferencesKey("has_saved_credentials") + + /** + * Marks that credentials have been successfully saved to the credential manager. + * + * @param context The Android context + */ + suspend fun setCredentialsSaved(context: Context) { + context.credentialDataStore.edit { prefs -> + prefs[KEY_HAS_SAVED_CREDENTIALS] = true + } + } + + /** + * Checks if credentials have been saved at least once. + * This prevents unnecessary credential retrieval attempts. + * + * @param context The Android context + * @return true if credentials have been saved, false otherwise + */ + suspend fun hasSavedCredentials(context: Context): Boolean { + val prefs = context.credentialDataStore.data.first() + return prefs[KEY_HAS_SAVED_CREDENTIALS] ?: false + } + + /** + * Clears the saved credentials flag. + * Useful for testing or when user signs out permanently. + * + * @param context The Android context + */ + suspend fun clearSavedCredentialsFlag(context: Context) { + context.credentialDataStore.edit { prefs -> + prefs.remove(KEY_HAS_SAVED_CREDENTIALS) + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt new file mode 100644 index 0000000000..070b852100 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +/** + * Constants for email link authentication. + * + * ## Usage Example: + * + * Check for email link in your MainActivity: + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val authUI = FirebaseAuthUI.getInstance() + * + * // Check if intent contains email link (from deep link) + * var emailLink: String? = null + * + * if (authUI.canHandleIntent(intent)) { + * emailLink = intent.data?.toString() + * } + * + * if (emailLink != null) { + * // Handle email link sign-in + * // Pass to FirebaseAuthScreen or handle manually + * } + * } + * ``` + * + * @since 10.0.0 + */ +object EmailLinkConstants { + + /** + * Intent extra key for the email link. + * + * Use this constant when passing email links between activities via Intent extras. + * + * **Example:** + * ```kotlin + * // Sending activity + * val intent = Intent(this, MainActivity::class.java) + * intent.putExtra(EmailLinkConstants.EXTRA_EMAIL_LINK, emailLink) + * startActivity(intent) + * + * // Receiving activity + * val emailLink = intent.getStringExtra(EmailLinkConstants.EXTRA_EMAIL_LINK) + * ``` + */ + const val EXTRA_EMAIL_LINK = "com.firebase.ui.auth.EXTRA_EMAIL_LINK" +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt new file mode 100644 index 0000000000..2841344d49 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.firebase.ui.auth.util + +import android.net.Uri +import androidx.annotation.RestrictTo +import androidx.core.net.toUri + +/** + * Parser for email link sign-in URLs. + * Extracts session information and parameters from Firebase email authentication links. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class EmailLinkParser(link: String) { + + private val params: Map + + init { + require(link.isNotBlank()) { "Link cannot be empty" } + params = parseUri(link.toUri()) + require(params.isNotEmpty()) { "Invalid link: no parameters found" } + } + + /** + * The out-of-band code (OOB code) from the email link. + * This is a required field for email link authentication. + * @throws IllegalArgumentException if the OOB code is missing from the link + */ + val oobCode: String + get() = requireNotNull(params[OOB_CODE]) { + "Invalid email link: missing required OOB code" + } + + val sessionId: String? + get() = params[LinkParameters.SESSION_IDENTIFIER] + + val anonymousUserId: String? + get() = params[LinkParameters.ANONYMOUS_USER_ID_IDENTIFIER] + + val forceSameDeviceBit: Boolean + get() { + val bit = params[LinkParameters.FORCE_SAME_DEVICE_IDENTIFIER] + // Default value is false when no bit is set + return bit == "1" + } + + val providerId: String? + get() = params[LinkParameters.PROVIDER_ID_IDENTIFIER] + + private fun parseUri(uri: Uri): Map { + val map = mutableMapOf() + try { + val queryParameters = uri.queryParameterNames + for (param in queryParameters) { + if (param.equals(LINK, ignoreCase = true) || + param.equals(CONTINUE_URL, ignoreCase = true)) { + val innerUriString = uri.getQueryParameter(param) + if (innerUriString != null) { + val innerUri = innerUriString.toUri() + val innerValues = parseUri(innerUri) + map.putAll(innerValues) + } + } else { + val value = uri.getQueryParameter(param) + if (value != null) { + map[param] = value + } + } + } + } catch (e: Exception) { + // Do nothing - return what we have + } + return map + } + + object LinkParameters { + const val SESSION_IDENTIFIER = "ui_sid" + const val ANONYMOUS_USER_ID_IDENTIFIER = "ui_auid" + const val FORCE_SAME_DEVICE_IDENTIFIER = "ui_sd" + const val PROVIDER_ID_IDENTIFIER = "ui_pid" + } + + companion object { + private const val LINK = "link" + private const val OOB_CODE = "oobCode" + private const val CONTINUE_URL = "continueUrl" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt new file mode 100644 index 0000000000..9cb080ca26 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FacebookAuthProvider +import com.google.firebase.auth.GoogleAuthProvider +import kotlinx.coroutines.flow.first + +private val Context.dataStore: DataStore by preferencesDataStore(name = "com.firebase.ui.auth.util.EmailLinkPersistenceManager") + +/** + * Manages saving/retrieving from DataStore for email link sign in. + * + * This class provides persistence for email link authentication sessions, including: + * - Email address + * - Session ID for same-device validation + * - Anonymous user ID for upgrade flows + * - Social provider credentials for linking flows + * + * @since 10.0.0 + */ +object EmailLinkPersistenceManager { + + /** + * Default instance. + */ + internal val default: PersistenceManager = DefaultPersistenceManager() + + /** + * The default implementation of [PersistenceManager] that uses DataStore. + */ + private class DefaultPersistenceManager : PersistenceManager { + override suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_EMAIL] = email + prefs[AuthProvider.Email.KEY_SESSION_ID] = sessionId + prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] = anonymousUserId ?: "" + } + } + + override suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) { + context.dataStore.edit { prefs -> + prefs[AuthProvider.Email.KEY_PROVIDER] = providerType + prefs[AuthProvider.Email.KEY_IDP_TOKEN] = idToken ?: "" + prefs[AuthProvider.Email.KEY_IDP_SECRET] = accessToken ?: "" + } + } + + override suspend fun retrieveSessionRecord(context: Context): SessionRecord? { + val prefs = context.dataStore.data.first() + val email = prefs[AuthProvider.Email.KEY_EMAIL] + val sessionId = prefs[AuthProvider.Email.KEY_SESSION_ID] + + if (email == null || sessionId == null) { + return null + } + + val anonymousUserId = prefs[AuthProvider.Email.KEY_ANONYMOUS_USER_ID] + val providerType = Provider.fromId(prefs[AuthProvider.Email.KEY_PROVIDER]) + val idToken = prefs[AuthProvider.Email.KEY_IDP_TOKEN] + val accessToken = prefs[AuthProvider.Email.KEY_IDP_SECRET] + + // Rebuild credential if we have provider data + val credentialForLinking = if (providerType != null && idToken != null) { + when (providerType) { + Provider.GOOGLE -> GoogleAuthProvider.getCredential(idToken, accessToken) + Provider.FACEBOOK -> FacebookAuthProvider.getCredential(accessToken ?: "") + else -> null + } + } else { + null + } + + return SessionRecord( + sessionId = sessionId, + email = email, + anonymousUserId = anonymousUserId, + credentialForLinking = credentialForLinking + ) + } + + override suspend fun clear(context: Context) { + context.dataStore.edit { prefs -> + prefs.remove(AuthProvider.Email.KEY_SESSION_ID) + prefs.remove(AuthProvider.Email.KEY_EMAIL) + prefs.remove(AuthProvider.Email.KEY_ANONYMOUS_USER_ID) + prefs.remove(AuthProvider.Email.KEY_PROVIDER) + prefs.remove(AuthProvider.Email.KEY_IDP_TOKEN) + prefs.remove(AuthProvider.Email.KEY_IDP_SECRET) + } + } + } + + /** + * Holds the necessary information to complete the email link sign in flow. + * + * @property sessionId Unique session identifier for same-device validation + * @property email Email address for sign-in + * @property anonymousUserId Optional anonymous user ID for upgrade flows + * @property credentialForLinking Optional social provider credential to link after sign-in + */ + data class SessionRecord( + val sessionId: String, + val email: String, + val anonymousUserId: String?, + val credentialForLinking: AuthCredential? + ) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt new file mode 100644 index 0000000000..13c4e24997 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +import android.content.Context + +/** + * Interface for managing email link authentication session persistence. + * + * This interface abstracts the persistence layer for email link sign-in sessions, + * allowing for different implementations (DataStore, in-memory for testing, etc.). + * + * @since 10.0.0 + */ +interface PersistenceManager { + + /** + * Saves email and session information for email link sign-in. + * + * @param context Android context for storage access + * @param email Email address to save + * @param sessionId Unique session identifier for same-device validation + * @param anonymousUserId Optional anonymous user ID for upgrade flows + */ + suspend fun saveEmail( + context: Context, + email: String, + sessionId: String, + anonymousUserId: String? + ) + + /** + * Saves social provider credential information for linking after email link sign-in. + * + * @param context Android context for storage access + * @param providerType Provider ID ("google.com", "facebook.com", etc.) + * @param idToken ID token from the provider + * @param accessToken Access token from the provider (optional, used by Facebook) + */ + suspend fun saveCredentialForLinking( + context: Context, + providerType: String, + idToken: String?, + accessToken: String? + ) + + /** + * Retrieves session information from storage. + * + * @param context Android context for storage access + * @return SessionRecord containing saved session data, or null if no session exists + */ + suspend fun retrieveSessionRecord(context: Context): EmailLinkPersistenceManager.SessionRecord? + + /** + * Clears all saved data from storage. + * + * @param context Android context for storage access + */ + suspend fun clear(context: Context) +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt new file mode 100644 index 0000000000..0d895189fb --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.firebase.ui.auth.util + +import androidx.annotation.RestrictTo +import com.firebase.ui.auth.util.CountryUtils + +/** + * Phone number validation utilities. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object PhoneNumberUtils { + + /** + * Validates if a string starts with a valid dial code. + * Accepts dial codes like "+1" or phone numbers like "+14155552671". + * Does NOT validate the full phone number, only checks if it starts with a valid country code. + * + * @param number The dial code or phone number to validate (should start with "+") + * @return true if the string starts with a valid country dial code, false otherwise + */ + fun isValid(number: String): Boolean { + if (!number.startsWith("+")) return false + + // Try to extract country code from the beginning (1-3 digits) + val digitsOnly = number.drop(1).takeWhile { it.isDigit() } + if (digitsOnly.isEmpty()) return false + + // Check if any prefix (1-3 digits) is a valid dial code + for (length in 1..minOf(3, digitsOnly.length)) { + val dialCode = "+${digitsOnly.take(length)}" + if (CountryUtils.findByDialCode(dialCode).isNotEmpty()) { + return true + } + } + return false + } + + /** + * Validates if a country ISO code or dial code is valid. + * Accepts both ISO codes (e.g., "US", "us") and dial codes (e.g., "+1"). + * + * @param code The ISO 3166-1 alpha-2 country code or E.164 dial code + * @return true if the code is a valid ISO code or dial code, false otherwise + */ + fun isValidIso(code: String?): Boolean { + if (code == null) return false + + // Check if it's a valid ISO country code (e.g., "US", "GB") + if (CountryUtils.findByCountryCode(code) != null) { + return true + } + + // Check if it's a valid dial code (e.g., "+1", "+44") + if (code.startsWith("+")) { + return CountryUtils.findByDialCode(code).isNotEmpty() + } + + return false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java new file mode 100644 index 0000000000..87817bedc3 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java @@ -0,0 +1,112 @@ +/* + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.battlelancer.seriesguide.backend.auth.util; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Bundle; + +import com.firebase.ui.auth.FirebaseAuthUI; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; + +/** + * Precondition checking utility methods. + */ +public final class Preconditions { + private Preconditions() { + // Helper classes shouldn't be instantiated + } + + /** + * Ensures that the provided value is not null, and throws a {@link NullPointerException} if it + * is null, with a message constructed from the provided error template and arguments. + */ + @NonNull + public static T checkNotNull( + @Nullable T val, + @NonNull String errorMessageTemplate, + @Nullable Object... errorMessageArgs) { + if (val == null) { + if (errorMessageArgs == null) { + throw new NullPointerException(errorMessageTemplate); + } else { + throw new NullPointerException(String.format(errorMessageTemplate, errorMessageArgs)); + } + } + return val; + } + + /** + * Ensures that the provided identifier matches a known style resource, and throws an {@link + * IllegalArgumentException} if the resource cannot be found, or is not a style resource, with a + * message constructed from the provided error template and arguments. + */ + @StyleRes + public static int checkValidStyle( + @NonNull Context context, + int styleId, + @NonNull String errorMessageTemplate, + @Nullable Object... errorMessageArguments) { + try { + String resourceType = context.getResources().getResourceTypeName(styleId); + if (!"style".equals(resourceType)) { + throw new IllegalArgumentException( + String.format(errorMessageTemplate, errorMessageArguments)); + } + return styleId; + } catch (Resources.NotFoundException ex) { + throw new IllegalArgumentException( + String.format(errorMessageTemplate, errorMessageArguments)); + } + } + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public static void checkUnset(@NonNull Bundle b, + @Nullable String message, + @NonNull String... keys) { + for (String key : keys) { + if (b.containsKey(key)) { throw new IllegalStateException(message); } + } + } + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public static void checkConfigured(@NonNull Context context, + @Nullable String message, + @StringRes int... ids) { + for (int id : ids) { + if (context.getString(id).equals(FirebaseAuthUI.UNCONFIGURED_CONFIG_VALUE)) { + throw new IllegalStateException(message); + } + } + } + + /** + * Ensures the truth of an expression involving parameters to the calling method. + * + * @param expression a boolean expression + * @param errorMessage the exception message to use if the check fails + * @throws IllegalArgumentException if {@code expression} is false + */ + public static void checkArgument(boolean expression, String errorMessage) { + if (!expression) { + throw new IllegalArgumentException(errorMessage); + } + } +} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt new file mode 100644 index 0000000000..2c75de102a --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.firebase.ui.auth.util + +import androidx.annotation.RestrictTo + +/** + * Utility for checking the availability of authentication providers at runtime. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object ProviderAvailability { + + /** + * Checks if Facebook authentication is available. + * Returns true if the Facebook SDK is present in the classpath. + */ + val IS_FACEBOOK_AVAILABLE: Boolean = classExists("com.facebook.login.LoginManager") + + private fun classExists(className: String): Boolean { + return try { + Class.forName(className) + true + } catch (e: ClassNotFoundException) { + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt new file mode 100644 index 0000000000..c2640a1744 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt @@ -0,0 +1,25 @@ +package com.firebase.ui.auth.util + +import androidx.annotation.RestrictTo +import kotlin.random.Random + +/** + * Utility for generating random session identifiers. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object SessionUtils { + + private const val VALID_CHARS = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + /** + * Generates a random alphanumeric string. + * + * @param length The desired length of the generated string. + * @return A randomly generated string with the desired number of characters. + */ + fun generateRandomAlphaNumericString(length: Int): String { + return (1..length) + .map { VALID_CHARS[Random.nextInt(VALID_CHARS.length)] } + .joinToString("") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt new file mode 100644 index 0000000000..7bfcc10f44 --- /dev/null +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.util + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +private val Context.signInPreferenceDataStore: DataStore by preferencesDataStore( + name = "com.firebase.ui.auth.util.SignInPreferenceManager" +) + +/** + * Manages persistence for the last-used sign-in method. + * + * This class tracks which authentication provider was last used to sign in, + * along with the user identifier (email, phone number, etc.). This enables + * a better UX by showing "Continue as [identifier]" with the last-used provider + * prominently on the method picker screen. + * + * @since 10.0.0 + */ +object SignInPreferenceManager { + + private val KEY_LAST_PROVIDER_ID = stringPreferencesKey("last_provider_id") + private val KEY_LAST_IDENTIFIER = stringPreferencesKey("last_identifier") + private val KEY_LAST_TIMESTAMP = longPreferencesKey("last_timestamp") + + /** + * Saves the last-used sign-in method and user identifier. + * + * This should be called after a successful sign-in to track the user's + * preferred sign-in method. + * + * @param context The Android context + * @param providerId The provider ID (e.g., "google.com", "facebook.com", "password", "phone") + * @param identifier The user identifier (email for social/email auth, phone number for phone auth) + */ + suspend fun saveLastSignIn( + context: Context, + providerId: String, + identifier: String? + ) { + context.signInPreferenceDataStore.edit { prefs -> + prefs[KEY_LAST_PROVIDER_ID] = providerId + identifier?.let { prefs[KEY_LAST_IDENTIFIER] = it } + prefs[KEY_LAST_TIMESTAMP] = System.currentTimeMillis() + } + } + + /** + * Retrieves the last-used sign-in preference. + * + * @param context The Android context + * @return [SignInPreference] containing the last-used provider and identifier, or null if none + */ + suspend fun getLastSignIn(context: Context): SignInPreference? { + val prefs = context.signInPreferenceDataStore.data.first() + val providerId = prefs[KEY_LAST_PROVIDER_ID] + val identifier = prefs[KEY_LAST_IDENTIFIER] + val timestamp = prefs[KEY_LAST_TIMESTAMP] + + return if (providerId != null && timestamp != null) { + SignInPreference( + providerId = providerId, + identifier = identifier, + timestamp = timestamp + ) + } else { + null + } + } + + /** + * Clears the saved sign-in preference. + * + * This should be called when the user signs out permanently or + * when resetting authentication state. + * + * @param context The Android context + */ + suspend fun clearLastSignIn(context: Context) { + context.signInPreferenceDataStore.edit { prefs -> + prefs.remove(KEY_LAST_PROVIDER_ID) + prefs.remove(KEY_LAST_IDENTIFIER) + prefs.remove(KEY_LAST_TIMESTAMP) + } + } + + /** + * Data class representing a saved sign-in preference. + * + * @property providerId The provider ID (e.g., "google.com", "facebook.com", "password", "phone") + * @property identifier The user identifier (email, phone number, etc.), may be null + * @property timestamp The timestamp when this preference was saved + */ + data class SignInPreference( + val providerId: String, + val identifier: String?, + val timestamp: Long + ) +} From 2f07e56dc77c13cf77b3ee3c0a6c4042d0e240df Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 1 May 2026 09:41:29 +0200 Subject: [PATCH 003/150] Auth: add missing license and copyright info --- .../auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt | 6 ++++++ .../auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt | 6 ++++++ .../auth_provider/OAuthProvider+FirebaseAuthUI.kt | 6 ++++++ .../auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt | 6 ++++++ .../auth/ui/components/VerificationCodeInputField.kt | 6 ++++++ .../seriesguide/backend/auth/util/CountryUtils.kt | 6 ++++++ .../seriesguide/backend/auth/util/SessionUtils.kt | 6 ++++++ 7 files changed, 42 insertions(+) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 009765727c..ad4dfc04c8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 4d18cb0a91..c4e5a2a8ee 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.configuration.auth_provider import android.content.Context diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index 4850657469..b5ca46db08 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index 0be8ee8fa6..6fb1ad4f98 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt index ab79e8954c..85dcb60aaa 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.ui.components import androidx.compose.animation.core.animateDpAsState diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt index a4a4a3cbee..cb23b5b793 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import com.firebase.ui.auth.data.ALL_COUNTRIES diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt index c2640a1744..24d7d8f879 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt @@ -1,3 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import androidx.annotation.RestrictTo From 8d31232a27a516fe9fd2635c68eb1f00d5c6fad4 Mon Sep 17 00:00:00 2001 From: Uwe Date: Fri, 1 May 2026 09:43:53 +0200 Subject: [PATCH 004/150] Auth: use SPDX tags, add source info --- .../seriesguide/backend/auth/AuthException.kt | 18 +++++------------- .../backend/auth/AuthFlowController.kt | 18 +++++------------- .../seriesguide/backend/auth/AuthState.kt | 18 +++++------------- .../backend/auth/FirebaseAuthActivity.kt | 18 +++++------------- .../backend/auth/FirebaseAuthUI.kt | 18 +++++------------- .../auth/FirebaseUIComposeRegistrar.kt | 18 +++++------------- .../auth/configuration/AuthUIConfiguration.kt | 18 +++++------------- .../auth/configuration/AuthUITransitions.kt | 18 +++++------------- .../auth/configuration/MfaConfiguration.kt | 18 +++++------------- .../backend/auth/configuration/MfaFactor.kt | 18 +++++------------- .../auth/configuration/PasswordRule.kt | 18 +++++------------- .../auth_provider/AuthProvider.kt | 18 +++++------------- .../EmailAuthProvider+FirebaseAuthUI.kt | 18 +++++------------- .../FacebookAuthProvider+FirebaseAuthUI.kt | 18 +++++------------- .../string_provider/AuthUIStringProvider.kt | 18 +++++------------- .../AuthUIStringProviderSample.kt | 18 +++++------------- .../DefaultAuthUIStringProvider.kt | 18 +++++------------- .../auth/configuration/theme/AuthUIAsset.kt | 18 +++++------------- .../auth/configuration/theme/AuthUITheme.kt | 18 +++++------------- .../theme/ProviderStyleDefaults.kt | 18 +++++------------- .../validators/EmailValidator.kt | 18 +++++------------- .../validators/FieldValidationStatus.kt | 18 +++++------------- .../validators/FieldValidator.kt | 18 +++++------------- .../validators/GeneralFieldValidator.kt | 18 +++++------------- .../validators/PasswordValidator.kt | 18 +++++------------- .../validators/PhoneNumberValidator.kt | 18 +++++------------- .../validators/VerificationCodeValidator.kt | 18 +++++------------- .../credentialmanager/PasswordCredential.kt | 18 +++++------------- .../PasswordCredentialHandler.kt | 18 +++++------------- .../backend/auth/data/Countries.kt | 18 +++++------------- .../backend/auth/data/CountryData.kt | 18 +++++------------- .../auth/mfa/MfaChallengeContentState.kt | 18 +++++------------- .../auth/mfa/MfaEnrollmentContentState.kt | 18 +++++------------- .../backend/auth/mfa/MfaEnrollmentStep.kt | 18 +++++------------- .../backend/auth/mfa/MfaErrorMapper.kt | 18 +++++------------- .../backend/auth/mfa/SmsEnrollmentHandler.kt | 18 +++++------------- .../backend/auth/mfa/TotpEnrollmentHandler.kt | 18 +++++------------- .../backend/auth/mfa/TotpSecret.kt | 18 +++++------------- .../auth/ui/components/AuthProviderButton.kt | 18 +++++------------- .../auth/ui/components/AuthTextField.kt | 18 +++++------------- .../auth/ui/components/CountrySelector.kt | 18 +++++------------- .../auth/ui/components/ErrorRecoveryDialog.kt | 18 +++++------------- .../backend/auth/ui/components/QrCodeImage.kt | 18 +++++------------- .../ui/components/ReauthenticationDialog.kt | 18 +++++------------- .../auth/ui/components/TermsAndPrivacyForm.kt | 18 +++++------------- .../ui/components/TopLevelDialogController.kt | 18 +++++------------- .../method_picker/AnnotatedStringResource.kt | 18 +++++------------- .../auth/ui/method_picker/AuthMethodPicker.kt | 18 +++++------------- .../auth/ui/screens/FirebaseAuthScreen.kt | 18 +++++------------- .../auth/ui/screens/MfaChallengeDefaults.kt | 18 +++++------------- .../auth/ui/screens/MfaChallengeScreen.kt | 18 +++++------------- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 18 +++++------------- .../auth/ui/screens/MfaEnrollmentScreen.kt | 18 +++++------------- .../auth/ui/screens/email/EmailAuthScreen.kt | 18 +++++------------- .../auth/ui/screens/email/ResetPasswordUI.kt | 18 +++++------------- .../ui/screens/email/SignInEmailLinkUI.kt | 18 +++++------------- .../backend/auth/ui/screens/email/SignInUI.kt | 18 +++++------------- .../backend/auth/ui/screens/email/SignUpUI.kt | 18 +++++------------- .../ui/screens/phone/EnterPhoneNumberUI.kt | 18 +++++------------- .../screens/phone/EnterVerificationCodeUI.kt | 18 +++++------------- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 18 +++++------------- .../backend/auth/util/ContinueUrlBuilder.kt | 19 ++++++------------- .../auth/util/CredentialPersistenceManager.kt | 18 +++++------------- .../backend/auth/util/EmailLinkConstants.kt | 18 +++++------------- .../backend/auth/util/EmailLinkParser.kt | 19 ++++++------------- .../auth/util/EmailLinkPersistenceManager.kt | 18 +++++------------- .../backend/auth/util/PersistenceManager.kt | 18 +++++------------- .../backend/auth/util/PhoneNumberUtils.kt | 19 ++++++------------- .../backend/auth/util/Preconditions.java | 18 +++++------------- .../backend/auth/util/ProviderAvailability.kt | 19 ++++++------------- .../auth/util/SignInPreferenceManager.kt | 18 +++++------------- 71 files changed, 359 insertions(+), 923 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt index 46d22f068c..7515fecc27 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt index 44cdf45aab..e2616a1890 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt index 061b33a453..a1a472a9dc 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt index 168670da1b..587fda3cd8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt index 9f829a37ff..ecb48a87a5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt index c985a29af7..ba861c4125 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt @@ -1,16 +1,8 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt index 3fa7f394bd..084d95db53 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt index b37dc34e19..f3231ddf83 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt index ed748bfe05..70f3a83d3d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt index 472740560d..131251209d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt index d10f1c8115..1bc52dcb34 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt index 5cf392a8c2..110b81b1bc 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.auth_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 8d4bae6d12..5b98324dbd 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.auth_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index 28ef456368..3bf2bca995 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.auth_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt index a062debddc..0a5e27d407 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.string_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt index cb82bee993..e746db1133 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.string_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 429d6d286d..31f742a585 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.string_provider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt index c59fcf66e4..8a2c488879 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.theme diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt index 79e78f80f2..a04113b42f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.theme diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt index c4721b395c..9adbf48ff3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.theme diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt index 0bcee25bcd..798ff9746f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt index a72313560c..42feebda7c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt index 4a6924f507..65c861045e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt index a72c9f80df..5bb556c6a5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt index b7a8a70eb6..45ce95c69b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt index 1d2484bbc4..f470ee9969 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt index 9824a81618..496aa794c8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.configuration.validators diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt index 535ace99d7..b218375d90 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.credentialmanager diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt index c83f9a280c..021f1b8cec 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.credentialmanager diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt index e6400cc600..263b5db166 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.data diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt index e171f47a8c..accfab6db3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.data diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt index 4311a7926f..4eda2a0a2f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt index 674cb42e60..903c8b57c7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt index 8d64da6202..7012e5b597 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt index 7776702ee6..03fac7e759 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt index 8aec7c9c3c..17544ccda2 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt index 33bc874c62..7fdb57930f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt index 7a4121ba8b..d4436bcb93 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.mfa diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt index 9a3f5e1a27..ac359b8615 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt index 253a6e260a..919db2d6a5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt index 425aa32bc6..0b967b8979 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt index dff4daa60c..8bcb3c2bf7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt index 754aa5cc78..20b787287c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt index 08c9ed89cc..e72f385ba3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt index 5cf33cd5a9..bba704a50f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt index 626c56c04e..a4446eb3de 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.components diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt index 7cf0c8ce5c..5645840ece 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.method_picker diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt index bf4c3b6a57..5096264d43 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.method_picker diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 5a065400cd..6a690aff01 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt index bca4bc4c5e..701f88b57f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt index 2dab06adbf..dd7d67743e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt index 61f1151a60..6df0e009e4 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt index 29f6827c9e..e8a4018a2c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt index 2ebc2542ff..23d64154c0 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.email diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt index 8b73f2c2d0..cf439dae94 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.email diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt index 14e0458279..c886dc8803 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.email diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt index eabde87a16..b9dab40f09 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.email diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt index dfc413bc6a..c68288222a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.email diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt index ef8bfe5454..96c367d609 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.phone diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt index 122e73a87a..8e3892a131 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.phone diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt index fa6278976d..7c9af12c16 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.ui.screens.phone diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt index 80efbd8bd4..089e4ddc9d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt @@ -1,16 +1,9 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import androidx.annotation.RestrictTo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt index e625344099..03b600b051 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.util diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt index 070b852100..f494289a1a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.util diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt index 2841344d49..b239ce64e8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt @@ -1,16 +1,9 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import android.net.Uri diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt index 9cb080ca26..a10dc542cf 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.util diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt index 13c4e24997..45c0dffaa5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.util diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt index 0d895189fb..7ee2f9a8a5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt @@ -1,16 +1,9 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import androidx.annotation.RestrictTo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java index 87817bedc3..4bf2bcb920 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java @@ -1,16 +1,8 @@ -/* - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2016 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.battlelancer.seriesguide.backend.auth.util; diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt index 2c75de102a..2644246da7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt @@ -1,16 +1,9 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android + package com.firebase.ui.auth.util import androidx.annotation.RestrictTo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt index 7bfcc10f44..73a56430f5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt @@ -1,16 +1,8 @@ -/* - * Copyright 2025 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the - * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing permissions and - * limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. + +// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android +// https://github.com/firebase/FirebaseUI-Android package com.firebase.ui.auth.util From 0215b3cb78f11c10199c5b4a9d7004cbed31ab94 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 15:08:08 +0100 Subject: [PATCH 005/150] Auth: update package names --- .../seriesguide/backend/auth/AuthException.kt | 4 +- .../backend/auth/AuthFlowController.kt | 6 ++- .../seriesguide/backend/auth/AuthState.kt | 6 +-- .../backend/auth/FirebaseAuthActivity.kt | 10 ++-- .../backend/auth/FirebaseAuthUI.kt | 10 ++-- .../auth/FirebaseUIComposeRegistrar.kt | 2 +- .../auth/configuration/AuthUIConfiguration.kt | 16 +++--- .../auth/configuration/AuthUITransitions.kt | 2 +- .../auth/configuration/MfaConfiguration.kt | 2 +- .../backend/auth/configuration/MfaFactor.kt | 2 +- .../auth/configuration/PasswordRule.kt | 6 +-- .../AnonymousAuthProvider+FirebaseAuthUI.kt | 8 +-- .../auth_provider/AuthProvider.kt | 18 +++---- .../EmailAuthProvider+FirebaseAuthUI.kt | 30 +++++------ .../FacebookAuthProvider+FirebaseAuthUI.kt | 14 +++--- .../GoogleAuthProvider+FirebaseAuthUI.kt | 14 +++--- .../OAuthProvider+FirebaseAuthUI.kt | 14 +++--- .../PhoneAuthProvider+FirebaseAuthUI.kt | 12 ++--- .../string_provider/AuthUIStringProvider.kt | 2 +- .../AuthUIStringProviderSample.kt | 8 +-- .../DefaultAuthUIStringProvider.kt | 2 +- .../auth/configuration/theme/AuthUIAsset.kt | 2 +- .../auth/configuration/theme/AuthUITheme.kt | 2 +- .../theme/ProviderStyleDefaults.kt | 4 +- .../validators/EmailValidator.kt | 4 +- .../validators/FieldValidationStatus.kt | 2 +- .../validators/FieldValidator.kt | 4 +- .../validators/GeneralFieldValidator.kt | 4 +- .../validators/PasswordValidator.kt | 6 +-- .../validators/PhoneNumberValidator.kt | 6 +-- .../validators/VerificationCodeValidator.kt | 4 +- .../credentialmanager/PasswordCredential.kt | 2 +- .../PasswordCredentialHandler.kt | 4 +- .../backend/auth/data/Countries.kt | 2 +- .../backend/auth/data/CountryData.kt | 2 +- .../auth/mfa/MfaChallengeContentState.kt | 4 +- .../auth/mfa/MfaEnrollmentContentState.kt | 6 +-- .../backend/auth/mfa/MfaEnrollmentStep.kt | 8 +-- .../backend/auth/mfa/MfaErrorMapper.kt | 4 +- .../backend/auth/mfa/SmsEnrollmentHandler.kt | 6 +-- .../backend/auth/mfa/TotpEnrollmentHandler.kt | 2 +- .../backend/auth/mfa/TotpSecret.kt | 2 +- .../auth/ui/components/AuthProviderButton.kt | 18 +++---- .../auth/ui/components/AuthTextField.kt | 12 ++--- .../auth/ui/components/CountrySelector.kt | 10 ++-- .../auth/ui/components/ErrorRecoveryDialog.kt | 6 +-- .../backend/auth/ui/components/QrCodeImage.kt | 2 +- .../ui/components/ReauthenticationDialog.kt | 6 +-- .../auth/ui/components/TermsAndPrivacyForm.kt | 2 +- .../ui/components/TopLevelDialogController.kt | 8 +-- .../components/VerificationCodeInputField.kt | 6 +-- .../method_picker/AnnotatedStringResource.kt | 2 +- .../auth/ui/method_picker/AuthMethodPicker.kt | 14 +++--- .../auth/ui/screens/FirebaseAuthScreen.kt | 50 ++++++++++--------- .../auth/ui/screens/MfaChallengeDefaults.kt | 12 ++--- .../auth/ui/screens/MfaChallengeScreen.kt | 10 ++-- .../auth/ui/screens/MfaEnrollmentDefaults.kt | 24 ++++----- .../auth/ui/screens/MfaEnrollmentScreen.kt | 28 +++++------ .../auth/ui/screens/email/EmailAuthScreen.kt | 30 +++++------ .../auth/ui/screens/email/ResetPasswordUI.kt | 18 +++---- .../ui/screens/email/SignInEmailLinkUI.kt | 18 +++---- .../backend/auth/ui/screens/email/SignInUI.kt | 32 ++++++------ .../backend/auth/ui/screens/email/SignUpUI.kt | 22 ++++---- .../ui/screens/phone/EnterPhoneNumberUI.kt | 24 ++++----- .../screens/phone/EnterVerificationCodeUI.kt | 18 +++---- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 26 +++++----- .../backend/auth/util/ContinueUrlBuilder.kt | 10 ++-- .../backend/auth/util/CountryUtils.kt | 6 +-- .../auth/util/CredentialPersistenceManager.kt | 2 +- .../backend/auth/util/EmailLinkConstants.kt | 2 +- .../backend/auth/util/EmailLinkParser.kt | 2 +- .../auth/util/EmailLinkPersistenceManager.kt | 6 +-- .../backend/auth/util/PersistenceManager.kt | 2 +- .../backend/auth/util/PhoneNumberUtils.kt | 4 +- .../backend/auth/util/Preconditions.java | 2 +- .../backend/auth/util/ProviderAvailability.kt | 2 +- .../backend/auth/util/SessionUtils.kt | 2 +- .../auth/util/SignInPreferenceManager.kt | 2 +- 78 files changed, 354 insertions(+), 354 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt index 7515fecc27..b194f33cfb 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth -import com.firebase.ui.auth.AuthException.Companion.from +import com.battlelancer.seriesguide.backend.auth.AuthException.Companion.from import com.google.firebase.FirebaseException import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.FirebaseAuthException diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt index e2616a1890..5a3980c268 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt @@ -4,13 +4,15 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth import android.app.Activity import android.content.Context import android.content.Intent import androidx.activity.result.ActivityResultLauncher -import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthActivity +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt index a1a472a9dc..6feca97c20 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth -import com.firebase.ui.auth.AuthState.Companion.Cancelled -import com.firebase.ui.auth.AuthState.Companion.Idle +import com.battlelancer.seriesguide.backend.auth.AuthState.Companion.Cancelled +import com.battlelancer.seriesguide.backend.auth.AuthState.Companion.Idle import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt index 587fda3cd8..dc5c9df16c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthActivity.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth import android.app.Activity import android.content.Context @@ -14,10 +14,10 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.lifecycle.lifecycleScope -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen -import com.firebase.ui.auth.util.EmailLinkConstants +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.ui.screens.FirebaseAuthScreen +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkConstants import kotlinx.coroutines.launch import java.util.UUID import java.util.concurrent.ConcurrentHashMap diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt index ecb48a87a5..d7c041bd3f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt @@ -4,15 +4,15 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth import android.content.Context import android.content.Intent import androidx.annotation.RestrictTo -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.signOutFromFacebook -import com.firebase.ui.auth.configuration.auth_provider.signOutFromGoogle +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signOutFromFacebook +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signOutFromGoogle import com.google.firebase.Firebase import com.google.firebase.FirebaseApp import com.google.firebase.auth.FirebaseAuth diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt index ba861c4125..cb2f9b6d6b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth +package com.battlelancer.seriesguide.backend.auth import android.util.Log import androidx.annotation.Keep diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt index 084d95db53..c5da082656 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt @@ -4,17 +4,17 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration +package com.battlelancer.seriesguide.backend.auth.configuration import android.content.Context import androidx.compose.ui.graphics.vector.ImageVector -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.AuthProvidersBuilder -import com.firebase.ui.auth.configuration.auth_provider.Provider -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvidersBuilder +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme import com.google.firebase.auth.ActionCodeSettings import java.util.Locale diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt index f3231ddf83..3ef01adb32 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUITransitions.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration +package com.battlelancer.seriesguide.backend.auth.configuration import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt index 70f3a83d3d..f5986bed53 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration +package com.battlelancer.seriesguide.backend.auth.configuration /** * Configuration class for Multi-Factor Authentication (MFA) enrollment and verification behavior. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt index 131251209d..ace7407a53 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration +package com.battlelancer.seriesguide.backend.auth.configuration /** * Represents the different Multi-Factor Authentication (MFA) factors that can be used diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt index 1bc52dcb34..fc79d0b4f5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/PasswordRule.kt @@ -4,13 +4,13 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration +package com.battlelancer.seriesguide.backend.auth.configuration -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider /** * An abstract class representing a set of validation rules that can be applied to a password field, - * typically within the [com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Email] configuration. + * typically within the [com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider.Email] configuration. */ abstract class PasswordRule { /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index ad4dfc04c8..ffab1ad2b8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -4,14 +4,14 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt index 110b81b1bc..5d11c8824e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.app.Activity import android.content.Context @@ -19,14 +19,14 @@ import androidx.credentials.GetCredentialRequest import androidx.datastore.preferences.core.stringPreferencesKey import com.facebook.AccessToken import com.firebase.ui.auth.R -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.AuthUIConfigurationDsl -import com.firebase.ui.auth.configuration.PasswordRule -import com.firebase.ui.auth.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.util.ContinueUrlBuilder -import com.firebase.ui.auth.util.PhoneNumberUtils -import com.firebase.ui.auth.util.Preconditions -import com.firebase.ui.auth.util.ProviderAvailability +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfigurationDsl +import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset +import com.battlelancer.seriesguide.backend.auth.util.ContinueUrlBuilder +import com.battlelancer.seriesguide.backend.auth.util.PhoneNumberUtils +import com.battlelancer.seriesguide.backend.auth.util.Preconditions +import com.battlelancer.seriesguide.backend.auth.util.ProviderAvailability import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.common.api.Scope diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index 5b98324dbd..e60e45dc1b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -4,26 +4,26 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.content.Context import android.net.Uri import android.util.Log import com.firebase.ui.auth.R -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile -import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler -import com.firebase.ui.auth.util.EmailLinkPersistenceManager -import com.firebase.ui.auth.util.EmailLinkParser -import com.firebase.ui.auth.util.PersistenceManager -import com.firebase.ui.auth.util.SessionUtils -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialCancelledException +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialException +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialHandler +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkParser +import com.battlelancer.seriesguide.backend.auth.util.PersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.SessionUtils +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.firebase.FirebaseApp import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.AuthCredential diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index 3bf2bca995..defe7c81f1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.content.Context import android.util.Log @@ -19,12 +19,12 @@ import com.facebook.FacebookCallback import com.facebook.FacebookException import com.facebook.login.LoginManager import com.facebook.login.LoginResult -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.util.EmailLinkPersistenceManager -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index c4e5a2a8ee..8453787869 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.content.Context import android.util.Log @@ -14,12 +14,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.util.EmailLinkPersistenceManager -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.android.gms.common.api.Scope import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import kotlinx.coroutines.CancellationException diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index b5ca46db08..76d97e64d7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -4,19 +4,19 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.firebase.auth.FirebaseAuthUserCollisionException import com.google.firebase.auth.OAuthCredential import com.google.firebase.auth.OAuthProvider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt index 6fb1ad4f98..1ce562a087 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt @@ -4,15 +4,15 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.auth_provider +package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.app.Activity import android.content.Context -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorSession import com.google.firebase.auth.PhoneAuthCredential diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt index 0a5e27d407..3fec269be1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.string_provider +package com.battlelancer.seriesguide.backend.auth.configuration.string_provider import androidx.compose.runtime.staticCompositionLocalOf diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt index e746db1133..887b3bb790 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt @@ -4,12 +4,12 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.string_provider +package com.battlelancer.seriesguide.backend.auth.configuration.string_provider import android.content.Context -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider class AuthUIStringProviderSample { /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 31f742a585..088ed4df7e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.string_provider +package com.battlelancer.seriesguide.backend.auth.configuration.string_provider import android.content.Context import android.content.res.Configuration diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt index 8a2c488879..ff3d0cf403 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUIAsset.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.theme +package com.battlelancer.seriesguide.backend.auth.configuration.theme import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt index a04113b42f..9a23f0471a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/AuthUITheme.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.theme +package com.battlelancer.seriesguide.backend.auth.configuration.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt index 9adbf48ff3..e7c0e89eee 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt @@ -4,11 +4,11 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.theme +package com.battlelancer.seriesguide.backend.auth.configuration.theme import androidx.compose.ui.graphics.Color import com.firebase.ui.auth.R -import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider /** * Default provider styling configurations for authentication providers. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt index 798ff9746f..4bfcad5d5e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/EmailValidator.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider internal class EmailValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt index 42feebda7c..95870280e6 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidationStatus.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators /** * Class for encapsulating [hasError] and [errorMessage] properties in diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt index 65c861045e..9093cf2bac 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/FieldValidator.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider /** * An interface for validating input fields. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt index 5bb556c6a5..3f67964724 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/GeneralFieldValidator.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider internal class GeneralFieldValidator( override val stringProvider: AuthUIStringProvider, diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt index 45ce95c69b..bbd2d9c299 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PasswordValidator.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.PasswordRule -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider internal class PasswordValidator( override val stringProvider: AuthUIStringProvider, diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt index f470ee9969..2a944a237d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.data.CountryData import com.google.i18n.phonenumbers.NumberParseException import com.google.i18n.phonenumbers.PhoneNumberUtil diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt index 496aa794c8..767532eb89 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/VerificationCodeValidator.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.configuration.validators +package com.battlelancer.seriesguide.backend.auth.configuration.validators -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider internal class VerificationCodeValidator(override val stringProvider: AuthUIStringProvider) : FieldValidator { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt index b218375d90..22721549de 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredential.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.credentialmanager +package com.battlelancer.seriesguide.backend.auth.credentialmanager /** * Represents a password credential retrieved from the system credential manager. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt index 021f1b8cec..643365b162 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/credentialmanager/PasswordCredentialHandler.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.credentialmanager +package com.battlelancer.seriesguide.backend.auth.credentialmanager import android.content.Context import androidx.credentials.CreatePasswordRequest @@ -17,7 +17,7 @@ import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException -import com.firebase.ui.auth.util.CredentialPersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.CredentialPersistenceManager /** * Provider interface for obtaining CredentialManager instances. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt index 263b5db166..241986877b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.data +package com.battlelancer.seriesguide.backend.auth.data /** * Complete list of countries with their dial codes and ISO country codes. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt index accfab6db3..49b16d6380 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.data +package com.battlelancer.seriesguide.backend.auth.data /** * Represents country information for phone number authentication. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt index 4eda2a0a2f..01d09d44c9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa -import com.firebase.ui.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor /** * State class containing all the necessary information to render a custom UI for the diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt index 903c8b57c7..b3850d027d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.data.CountryData import com.google.firebase.auth.MultiFactorInfo /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt index 7012e5b597..b6fa5ba231 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider /** * Represents the different steps in the Multi-Factor Authentication (MFA) enrollment flow. @@ -20,7 +20,7 @@ import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider enum class MfaEnrollmentStep { /** * The user is presented with a selection of available MFA factors to enroll in. - * The available factors are determined by the [com.firebase.ui.auth.configuration.MfaConfiguration]. + * The available factors are determined by the [com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration]. */ SelectFactor, diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt index 03fac7e759..ff69048a2f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaErrorMapper.kt @@ -4,9 +4,9 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider import com.google.firebase.FirebaseNetworkException import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt index 17544ccda2..06f5121c7a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt @@ -4,11 +4,11 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa import android.app.Activity -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.PhoneAuthCredential diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt index 7fdb57930f..d0fd358d7d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpEnrollmentHandler.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt index d4436bcb93..5e09e3651c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/TotpSecret.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.mfa +package com.battlelancer.seriesguide.backend.auth.mfa import android.content.Intent import android.net.Uri diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt index ac359b8615..43d06a8d02 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import android.content.Context import androidx.compose.foundation.Image @@ -33,14 +33,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.Provider -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme -import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.theme.LocalAuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.theme.ProviderStyleDefaults /** * A customizable button for an authentication provider. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt index 919db2d6a5..8b0b739407 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthTextField.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -38,11 +38,11 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.PasswordRule -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.configuration.validators.EmailValidator -import com.firebase.ui.auth.configuration.validators.FieldValidator -import com.firebase.ui.auth.configuration.validators.PasswordValidator +import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator +import com.battlelancer.seriesguide.backend.auth.configuration.validators.FieldValidator +import com.battlelancer.seriesguide.backend.auth.configuration.validators.PasswordValidator /** * A customizable input field with built-in validation display. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt index 0b967b8979..403602c045 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -46,10 +46,10 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.data.ALL_COUNTRIES -import com.firebase.ui.auth.data.CountryData -import com.firebase.ui.auth.util.CountryUtils +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.data.ALL_COUNTRIES +import com.battlelancer.seriesguide.backend.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.util.CountryUtils import kotlinx.coroutines.launch /** diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt index 8bcb3c2bf7..66a01a82d7 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -14,8 +14,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider /** * A composable dialog for displaying authentication errors with recovery options. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt index 20b787287c..0b93d6e26c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/QrCodeImage.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import android.graphics.Bitmap import androidx.compose.foundation.Image diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt index e72f385ba3..b418cf2def 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ReauthenticationDialog.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,8 +35,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider import com.google.firebase.auth.EmailAuthProvider import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt index bba704a50f..ee5feb756c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt index a4446eb3de..36dba44ac6 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TopLevelDialogController.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf @@ -12,9 +12,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider /** * CompositionLocal for accessing the top-level dialog controller from any composable. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt index 85dcb60aaa..719fbba654 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.components +package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween @@ -63,8 +63,8 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.core.text.isDigitsOnly -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.FieldValidator +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.FieldValidator @Composable fun VerificationCodeInputField( diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt index 5645840ece..8500e990d8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AnnotatedStringResource.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.method_picker +package com.battlelancer.seriesguide.backend.auth.ui.method_picker import android.content.Context import android.content.Intent diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt index 5096264d43..62020d688a 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.method_picker +package com.battlelancer.seriesguide.backend.auth.ui.method_picker import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -31,12 +31,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.firebase.ui.auth.R -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.Provider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUIAsset -import com.firebase.ui.auth.ui.components.AuthProviderButton -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthProviderButton +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager /** * Renders the provider selection screen. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 6a690aff01..7ffcb77a3d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens +package com.battlelancer.seriesguide.backend.auth.ui.screens import android.util.Log import androidx.activity.compose.LocalActivity @@ -47,29 +47,31 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.BuildConfig -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.MfaConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.rememberAnonymousSignInHandler -import com.firebase.ui.auth.configuration.auth_provider.rememberGoogleSignInHandler -import com.firebase.ui.auth.configuration.auth_provider.rememberOAuthSignInHandler -import com.firebase.ui.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher -import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.LocalAuthUITheme -import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController -import com.firebase.ui.auth.ui.components.rememberTopLevelDialogController -import com.firebase.ui.auth.ui.method_picker.AuthMethodPicker -import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen -import com.firebase.ui.auth.ui.screens.phone.PhoneAuthScreen -import com.firebase.ui.auth.util.EmailLinkPersistenceManager -import com.firebase.ui.auth.util.SignInPreferenceManager +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.BuildConfig +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberAnonymousSignInHandler +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberGoogleSignInHandler +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberOAuthSignInHandler +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithEmailLink +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.LocalAuthUITheme +import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController +import com.battlelancer.seriesguide.backend.auth.ui.components.rememberTopLevelDialogController +import com.battlelancer.seriesguide.backend.auth.ui.method_picker.AuthMethodPicker +import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaChallengeScreen +import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaEnrollmentScreen +import com.battlelancer.seriesguide.backend.auth.ui.screens.email.EmailAuthScreen +import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.PhoneAuthScreen +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager +import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import com.google.firebase.auth.MultiFactorResolver diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt index 701f88b57f..100bbd69db 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens +package com.battlelancer.seriesguide.backend.auth.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,11 +28,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator -import com.firebase.ui.auth.mfa.MfaChallengeContentState -import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.validators.VerificationCodeValidator +import com.battlelancer.seriesguide.backend.auth.mfa.MfaChallengeContentState +import com.battlelancer.seriesguide.backend.auth.ui.components.VerificationCodeInputField @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt index dd7d67743e..d3f57bb996 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens +package com.battlelancer.seriesguide.backend.auth.ui.screens import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable @@ -14,10 +14,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.mfa.MfaChallengeContentState -import com.firebase.ui.auth.mfa.SmsEnrollmentHandler -import com.firebase.ui.auth.mfa.maskPhoneNumber +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.mfa.MfaChallengeContentState +import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler +import com.battlelancer.seriesguide.backend.auth.mfa.maskPhoneNumber import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.MultiFactorResolver diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt index 6df0e009e4..4e32ab6e97 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens +package com.battlelancer.seriesguide.backend.auth.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,17 +41,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.mfa.MfaEnrollmentContentState -import com.firebase.ui.auth.mfa.MfaEnrollmentStep -import com.firebase.ui.auth.mfa.toMfaErrorMessage -import com.firebase.ui.auth.ui.components.QrCodeImage -import com.firebase.ui.auth.ui.components.ReauthenticationDialog -import com.firebase.ui.auth.ui.screens.phone.EnterPhoneNumberUI -import com.firebase.ui.auth.ui.screens.phone.EnterVerificationCodeUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentContentState +import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentStep +import com.battlelancer.seriesguide.backend.auth.mfa.toMfaErrorMessage +import com.battlelancer.seriesguide.backend.auth.ui.components.QrCodeImage +import com.battlelancer.seriesguide.backend.auth.ui.components.ReauthenticationDialog +import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.EnterPhoneNumberUI +import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.EnterVerificationCodeUI import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorInfo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt index e8a4018a2c..e0ff556c8b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens +package com.battlelancer.seriesguide.backend.auth.ui.screens import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable @@ -15,19 +15,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalContext -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.MfaConfiguration -import com.firebase.ui.auth.configuration.MfaFactor -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.data.CountryData -import com.firebase.ui.auth.util.CountryUtils -import com.firebase.ui.auth.mfa.MfaEnrollmentContentState -import com.firebase.ui.auth.mfa.MfaEnrollmentStep -import com.firebase.ui.auth.mfa.SmsEnrollmentHandler -import com.firebase.ui.auth.mfa.SmsEnrollmentSession -import com.firebase.ui.auth.mfa.TotpEnrollmentHandler -import com.firebase.ui.auth.mfa.TotpSecret +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.util.CountryUtils +import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentContentState +import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentStep +import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler +import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentSession +import com.battlelancer.seriesguide.backend.auth.mfa.TotpEnrollmentHandler +import com.battlelancer.seriesguide.backend.auth.mfa.TotpSecret import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt index 23d64154c0..f0d6cbf70f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/EmailAuthScreen.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.email +package com.battlelancer.seriesguide.backend.auth.ui.screens.email import android.content.Context import android.util.Log @@ -16,22 +16,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.createOrLinkUserWithEmailAndPassword -import com.firebase.ui.auth.configuration.auth_provider.sendPasswordResetEmail -import com.firebase.ui.auth.configuration.auth_provider.sendSignInLinkToEmail -import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailAndPassword -import com.firebase.ui.auth.configuration.auth_provider.signInWithEmailLink -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler -import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException -import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.createOrLinkUserWithEmailAndPassword +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.sendPasswordResetEmail +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.sendSignInLinkToEmail +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithEmailAndPassword +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithEmailLink +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt index cf439dae94..6e08f53359 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/ResetPasswordUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.email +package com.battlelancer.seriesguide.backend.auth.ui.screens.email import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -39,14 +39,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.EmailValidator -import com.firebase.ui.auth.ui.components.AuthTextField -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt index c886dc8803..ce8df1d818 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInEmailLinkUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.email +package com.battlelancer.seriesguide.backend.auth.ui.screens.email import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -44,14 +44,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.EmailValidator -import com.firebase.ui.auth.ui.components.AuthTextField -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm import com.google.firebase.auth.actionCodeSettings @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt index b9dab40f09..f559f5367e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignInUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.email +package com.battlelancer.seriesguide.backend.auth.ui.screens.email import android.util.Log import androidx.compose.foundation.layout.Column @@ -52,21 +52,21 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.EmailValidator -import com.firebase.ui.auth.configuration.validators.PasswordValidator -import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialException -import com.firebase.ui.auth.credentialmanager.PasswordCredentialHandler -import com.firebase.ui.auth.credentialmanager.PasswordCredentialNotFoundException -import com.firebase.ui.auth.ui.components.AuthTextField -import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator +import com.battlelancer.seriesguide.backend.auth.configuration.validators.PasswordValidator +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialCancelledException +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialException +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialHandler +import com.battlelancer.seriesguide.backend.auth.credentialmanager.PasswordCredentialNotFoundException +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField +import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt index c68288222a..85c98e96da 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/email/SignUpUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.email +package com.battlelancer.seriesguide.backend.auth.ui.screens.email import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -34,16 +34,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.EmailValidator -import com.firebase.ui.auth.configuration.validators.GeneralFieldValidator -import com.firebase.ui.auth.configuration.validators.PasswordValidator -import com.firebase.ui.auth.ui.components.AuthTextField -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.EmailValidator +import com.battlelancer.seriesguide.backend.auth.configuration.validators.GeneralFieldValidator +import com.battlelancer.seriesguide.backend.auth.configuration.validators.PasswordValidator +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt index 96c367d609..dce292c020 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.phone +package com.battlelancer.seriesguide.backend.auth.ui.screens.phone import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,17 +35,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.PhoneNumberValidator -import com.firebase.ui.auth.data.CountryData -import com.firebase.ui.auth.ui.components.AuthTextField -import com.firebase.ui.auth.ui.components.CountrySelector -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm -import com.firebase.ui.auth.util.CountryUtils +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.PhoneNumberValidator +import com.battlelancer.seriesguide.backend.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField +import com.battlelancer.seriesguide.backend.auth.ui.components.CountrySelector +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.util.CountryUtils @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt index 8e3892a131..553e358bce 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.phone +package com.battlelancer.seriesguide.backend.auth.ui.screens.phone import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -38,14 +38,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.authUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.configuration.theme.AuthUITheme -import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator -import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm -import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme +import com.battlelancer.seriesguide.backend.auth.configuration.validators.VerificationCodeValidator +import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm +import com.battlelancer.seriesguide.backend.auth.ui.components.VerificationCodeInputField import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt index 7c9af12c16..a4f4e1ef57 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.ui.screens.phone +package com.battlelancer.seriesguide.backend.auth.ui.screens.phone import android.content.Context import android.util.Log @@ -19,18 +19,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import com.firebase.ui.auth.AuthException -import com.firebase.ui.auth.AuthState -import com.firebase.ui.auth.FirebaseAuthUI -import com.firebase.ui.auth.configuration.AuthUIConfiguration -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.signInWithPhoneAuthCredential -import com.firebase.ui.auth.configuration.auth_provider.submitVerificationCode -import com.firebase.ui.auth.configuration.auth_provider.verifyPhoneNumber -import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.data.CountryData -import com.firebase.ui.auth.ui.components.LocalTopLevelDialogController -import com.firebase.ui.auth.util.CountryUtils +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthState +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithPhoneAuthCredential +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.submitVerificationCode +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.verifyPhoneNumber +import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider +import com.battlelancer.seriesguide.backend.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController +import com.battlelancer.seriesguide.backend.auth.util.CountryUtils import com.google.firebase.auth.AuthResult import com.google.firebase.auth.PhoneAuthProvider import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt index 089e4ddc9d..00db3ee1a1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ContinueUrlBuilder.kt @@ -4,13 +4,13 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import androidx.annotation.RestrictTo -import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.ANONYMOUS_USER_ID_IDENTIFIER -import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.FORCE_SAME_DEVICE_IDENTIFIER -import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.PROVIDER_ID_IDENTIFIER -import com.firebase.ui.auth.util.EmailLinkParser.LinkParameters.SESSION_IDENTIFIER +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkParser.LinkParameters.ANONYMOUS_USER_ID_IDENTIFIER +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkParser.LinkParameters.FORCE_SAME_DEVICE_IDENTIFIER +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkParser.LinkParameters.PROVIDER_ID_IDENTIFIER +import com.battlelancer.seriesguide.backend.auth.util.EmailLinkParser.LinkParameters.SESSION_IDENTIFIER /** * Builder for constructing continue URLs with embedded session and authentication parameters. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt index cb23b5b793..256d344b67 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util -import com.firebase.ui.auth.data.ALL_COUNTRIES -import com.firebase.ui.auth.data.CountryData +import com.battlelancer.seriesguide.backend.auth.data.ALL_COUNTRIES +import com.battlelancer.seriesguide.backend.auth.data.CountryData import java.text.Normalizer import java.util.Locale diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt index 03b600b051..f8486fde3e 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CredentialPersistenceManager.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import android.content.Context import androidx.datastore.core.DataStore diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt index f494289a1a..f5bede5221 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkConstants.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util /** * Constants for email link authentication. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt index b239ce64e8..a65c6b3b08 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkParser.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import android.net.Uri import androidx.annotation.RestrictTo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt index a10dc542cf..af1c751716 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt @@ -4,15 +4,15 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore -import com.firebase.ui.auth.configuration.auth_provider.AuthProvider -import com.firebase.ui.auth.configuration.auth_provider.Provider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.GoogleAuthProvider diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt index 45c0dffaa5..17caa436b4 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PersistenceManager.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import android.content.Context diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt index 7ee2f9a8a5..9ca24116c9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt @@ -4,10 +4,10 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import androidx.annotation.RestrictTo -import com.firebase.ui.auth.util.CountryUtils +import com.battlelancer.seriesguide.backend.auth.util.CountryUtils /** * Phone number validation utilities. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java index 4bf2bcb920..984057e12c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/Preconditions.java @@ -10,7 +10,7 @@ import android.content.res.Resources; import android.os.Bundle; -import com.firebase.ui.auth.FirebaseAuthUI; +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI; import androidx.annotation.NonNull; import androidx.annotation.Nullable; diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt index 2644246da7..3832a569db 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import androidx.annotation.RestrictTo diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt index 24d7d8f879..8089f0b544 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SessionUtils.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import androidx.annotation.RestrictTo import kotlin.random.Random diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt index 73a56430f5..66456500c8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/SignInPreferenceManager.kt @@ -4,7 +4,7 @@ // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android -package com.firebase.ui.auth.util +package com.battlelancer.seriesguide.backend.auth.util import android.content.Context import androidx.datastore.core.DataStore From af2a597fd03a57bf2b95691f7ff87330fb30e544 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 09:36:29 +0100 Subject: [PATCH 006/150] Auth: copy string resources from FirebaseUI --- .../EmailAuthProvider+FirebaseAuthUI.kt | 2 +- .../DefaultAuthUIStringProvider.kt | 2 +- .../auth/ui/components/TermsAndPrivacyForm.kt | 2 +- .../auth/ui/method_picker/AuthMethodPicker.kt | 2 +- app/src/main/res/values/strings.xml | 253 +++++++++++++++++- 5 files changed, 256 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt index e60e45dc1b..27a3cbce05 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt @@ -9,7 +9,7 @@ package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider import android.content.Context import android.net.Uri import android.util.Log -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.backend.auth.AuthException import com.battlelancer.seriesguide.backend.auth.AuthState import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 088ed4df7e..7e55365358 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -8,7 +8,7 @@ package com.battlelancer.seriesguide.backend.auth.configuration.string_provider import android.content.Context import android.content.res.Configuration -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R import java.util.Locale class DefaultAuthUIStringProvider( diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt index ee5feb756c..cf00f1a11d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/TermsAndPrivacyForm.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R @Composable fun TermsAndPrivacyForm( diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt index 62020d688a..c8ec576dfd 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04c83f0ee9..4c9716c6c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@  - + Shows @@ -628,4 +628,255 @@ Purchase Rental + + + @string/app_name + Loading… + Sign in + Continue + By continuing, you are indicating that you accept our %1$s and %2$s. + %1$s \u00A0 \u00A0 %2$s + Network error, check your internet connection. + + + Google + Facebook + Twitter + GitHub + Phone + Email + + + "Auth method picker logo" + Sign in with Google + Sign in with Facebook + Sign in with X + Sign in with GitHub + Sign in with email + Sign in with phone + Continue as guest + Sign in with Apple + Sign in with Microsoft + Sign in with Yahoo + Continue with Google + Continue with Facebook + Continue with X + Continue with GitHub + Continue with email + Continue with phone + Continue with Apple + Continue with Microsoft + Continue with Yahoo + + + Signed in as %1$s + Manage multi-factor authentication + Sign out + Please verify %1$s to continue. + Resend verification email + I\'ve verified my email + Please complete your profile information to continue. + Missing fields: %1$s + Skip for now + Remove + Back + Verify + Use a different method + I\'ve saved these codes + Secret key + Verification code + Identity verified. Please try your action again. + + + Manage two-factor authentication + Add or remove authentication methods for your account + Active methods + Add new method + All available authentication methods are enrolled + SMS authentication + Authenticator app + Unknown method + Enrolled on %1$s + Scan the QR code or enter the secret key in your authenticator app + Choose a verification method + Add an extra layer of security + SMS + Authenticator app + This phone number is associated with another account + Verification required + Scan the QR code with your authenticator app + Authenticator app is already set up + Are you sure you want to remove this method? + Unable to remove method + Method removed + Enter the verification code + + + Verify your identity + For your security, please re-enter your password to continue. + Account: %1$s + Incorrect password. Please try again. + Authentication failed. Please try again. + Confirm your identity to continue + Re-authentication required + Re-authentication successful + Re-authenticate + + + Unable to update profile + Profile updated + + + Next + Email + Phone Number + Country + Select a country + Select for country e.g. +1, "US" + Password + Confirm Password + New password + You can\'t leave this empty. + That email address isn\'t correct + Enter your email address to continue + Please enter a first and last name. + Checking for existing accounts… + + + Sign up + First & last name + Save + Signing up… + + Password not strong enough. Use at least %1$d character and a mix of letters and numbers + Password not strong enough. Use at least %1$d characters and a mix of letters and numbers + + Email account registration unsuccessful + Terms of Service + Privacy Policy + email_field + + + @string/fui_sign_in_default + @string/fui_sign_in_default + You already have an account + Welcome back! + You already have an account\n + + + You\'ve already used %1$s. + Sign in with %2$s to continue. + + + You\'ve already used %1$s + to sign in. Enter your password for that account. + + + You\'ve already used %1$s. + You can connect your %2$s account with %1$s by signing in with email link below.\n\nFor this flow to successfully connect your %2$s account with this email, you have to open the link on the same device or browser. + + Signing in… + Trouble signing in? + + + Recover password + Check your email + Get instructions sent to this email that explain how to + reset your password. + Send + Follow the instructions sent to %1$s to recover your password. + Sending… + That email address doesn\'t match an existing account + + + An unknown error occurred. + Incorrect password. + + + Passwords do not match + Password must be at least %1$d characters long + Password must contain at least one uppercase letter + Password must contain at least one lowercase letter + Password must contain at least one number + Password must contain at least one special character + + + App logo + + + + or Continue with + Sign in with email link + Sign in with password + Sign-in email sent\n + A sign-in email with additional instructions was sent to %1$s. Check your email to complete sign-in. + Trouble getting email? + Try these common fixes:\n\u2022 Check if the email was marked as spam or filtered.\n\u2022 Check your internet connection.\n\u2022 Check that you did not misspell your email.\n\u2022 Check that your inbox space is not running out or other inbox setting related issues.\n + Resend + New device or browser detected. + Try opening the link using the same device or browser where you started the sign-in process. + Unable to complete sign in + The action code is invalid. This can happen if the code is malformed, expired, or has already been used. + Session ended + The session associated with this sign-in request has either expired or was cleared. + Confirm email + Confirm email to continue sign in + Dismiss + You originally intended to connect %1$s to your email account but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device where you started sign-in. Otherwise, tap Continue to sign-in on this device. + + + + Enter your phone number + Enter a valid phone number + Enter the 6-digit code we sent to + Resend code in %1$s + Verify your phone number + Verifying… + Wrong code. Try again. + This phone number has been used too many times + There was a problem verifying your phone number + This code is no longer valid + Sign in with phone number + Phone number automatically verified + Resend Code + Verify Phone Number + Use a different phone number + By tapping "%1$s", an SMS may be sent. Message & data rates may apply. + By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. + + + Authentication Error + Try again + Additional verification required. Please complete multi-factor authentication. + Account needs to be linked. Please try a different sign-in method. + Authentication was cancelled. Please try again when ready. + + + Choose Authentication Method + Set Up SMS Verification + Set Up Authenticator App + Verify Your Code + Save Your Recovery Codes + + Select a second authentication method to secure your account + Enter your phone number to receive verification codes + Scan the QR code with your authenticator app + Enter the code sent to your phone + Enter the code from your authenticator app + Enter your verification code + Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. + + Set Up SMS Authentication + Verify SMS Code + + + This operation requires recent authentication. Please sign in again and try again. + The verification code is incorrect. Please try again. + A network error occurred. Please check your connection and try again. + An error occurred during enrollment. Please try again. + + + This button is currently disabled because new accounts are not allowed + Multi-factor authentication is currently disabled + From 12ad45144012a8bce2fa5a3d2dad232a7f110fe2 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 09:43:22 +0100 Subject: [PATCH 007/150] Auth: copy drawable resources from FirebaseUI --- .../theme/ProviderStyleDefaults.kt | 2 +- .../fui_ic_check_circle_black_128dp.xml | 9 +++++++++ .../drawable/fui_ic_googleg_color_24dp.xml | 19 +++++++++++++++++++ .../res/drawable/fui_ic_mail_white_24dp.xml | 10 ++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/fui_ic_check_circle_black_128dp.xml create mode 100644 app/src/main/res/drawable/fui_ic_googleg_color_24dp.xml create mode 100644 app/src/main/res/drawable/fui_ic_mail_white_24dp.xml diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt index e7c0e89eee..f7f54deb36 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt @@ -7,7 +7,7 @@ package com.battlelancer.seriesguide.backend.auth.configuration.theme import androidx.compose.ui.graphics.Color -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider /** diff --git a/app/src/main/res/drawable/fui_ic_check_circle_black_128dp.xml b/app/src/main/res/drawable/fui_ic_check_circle_black_128dp.xml new file mode 100644 index 0000000000..d6d693711f --- /dev/null +++ b/app/src/main/res/drawable/fui_ic_check_circle_black_128dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/fui_ic_googleg_color_24dp.xml b/app/src/main/res/drawable/fui_ic_googleg_color_24dp.xml new file mode 100644 index 0000000000..a818c59456 --- /dev/null +++ b/app/src/main/res/drawable/fui_ic_googleg_color_24dp.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/fui_ic_mail_white_24dp.xml b/app/src/main/res/drawable/fui_ic_mail_white_24dp.xml new file mode 100644 index 0000000000..66832c1ece --- /dev/null +++ b/app/src/main/res/drawable/fui_ic_mail_white_24dp.xml @@ -0,0 +1,10 @@ + + + From 49c37e35331ead1652810daa656bf0f99e993683 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 15:08:25 +0100 Subject: [PATCH 008/150] Auth: update copyright header --- .../seriesguide/backend/auth/AuthFlowController.kt | 3 ++- .../seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt index 5a3980c268..099568be2d 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthFlowController.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 7ffcb77a3d..0921c8b037 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android From a0a028fa58bede87ada4e5d55dc23652f972c5be Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 15:22:40 +0100 Subject: [PATCH 009/150] Auth: prepare to use new auth UI --- .../seriesguide/backend/CloudSetupFragment.kt | 162 ++++++++++-------- .../seriesguide/backend/HexagonAuthError.kt | 4 +- .../seriesguide/backend/HexagonTools.kt | 12 -- .../RemoveCloudAccountDialogFragment.kt | 51 +++--- .../seriesguide/backend/auth/AuthException.kt | 3 +- 5 files changed, 118 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt index 50b390f9fa..b7a806d5e2 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/CloudSetupFragment.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Copyright 2019-2025 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2019 Uwe Trottmann package com.battlelancer.seriesguide.backend @@ -15,6 +15,12 @@ import androidx.core.view.isGone import androidx.fragment.app.Fragment import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp +import com.battlelancer.seriesguide.backend.auth.AuthException +import com.battlelancer.seriesguide.backend.auth.AuthFlowController +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthActivity +import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI +import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration +import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider import com.battlelancer.seriesguide.backend.settings.HexagonSettings import com.battlelancer.seriesguide.billing.BillingTools import com.battlelancer.seriesguide.databinding.FragmentCloudSetupBinding @@ -24,14 +30,12 @@ import com.battlelancer.seriesguide.traktapi.ConnectTraktActivity import com.battlelancer.seriesguide.util.Errors import com.battlelancer.seriesguide.util.ThemeUtils import com.battlelancer.seriesguide.util.safeShow -import com.firebase.ui.auth.AuthMethodPickerLayout -import com.firebase.ui.auth.AuthUI -import com.firebase.ui.auth.ErrorCodes -import com.firebase.ui.auth.IdpResponse import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import com.uwetrottmann.androidutils.AndroidUtils +import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -125,6 +129,7 @@ class CloudSetupFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() binding = null + authController?.dispose() } @Subscribe(threadMode = ThreadMode.MAIN) @@ -188,52 +193,48 @@ class CloudSetupFragment : Fragment() { if (result.resultCode == Activity.RESULT_OK) { changeAccount(FirebaseAuth.getInstance().currentUser, null) } else { - val response = IdpResponse.fromResultIntent(result.data) - if (response == null) { + val error = result.data + ?.let { + if (AndroidUtils.isAtLeastTiramisu) { + it.getSerializableExtra( + FirebaseAuthActivity.EXTRA_ERROR, + AuthException::class.java + ) + } else { + @Suppress("DEPRECATION") + it.getSerializableExtra(FirebaseAuthActivity.EXTRA_ERROR) + as? AuthException + } + } + if (error == null) { // user chose not to sign in or add account, show no error message changeAccount(null, null) } else { val errorMessage: String? - val ex = response.error - if (ex != null) { - when (ex.errorCode) { - ErrorCodes.NO_NETWORK -> { - errorMessage = getString(R.string.offline) - } + when (error) { + is AuthException.NetworkException -> { + errorMessage = getString(R.string.offline) + } - ErrorCodes.PLAY_SERVICES_UPDATE_CANCELLED -> { - // user cancelled, show no error message - errorMessage = null - } + is AuthException.AuthCancelledException -> { + // user cancelled, show no error message + errorMessage = null + } - else -> { - if (ex.errorCode == ErrorCodes.DEVELOPER_ERROR - && !hexagonTools.isGoogleSignInAvailable) { - // Note: If trying to sign-in with email already used with - // Google Sign-In on other device, fails to fall back to - // Google Sign-In because Play Services is not available. - errorMessage = getString(R.string.hexagon_signin_google_only) - } else { - // Note: error code in message can be from Firebase Auth UI - // https://github.com/firebase/FirebaseUI-Android/blob/master/auth/src/main/java/com/firebase/ui/auth/ErrorCodes.java - // or from Play Services API - // https://developers.google.com/android/reference/com/google/android/gms/common/api/CommonStatusCodes - // despite being wrapped in FirebaseUiException. - // The message format is a good hint, e.g. - // "Code: 10, message: 10: " is from CommonStatusCodes - // See also HexagonAuthError.build() - errorMessage = ex.message - Errors.logAndReportHexagonAuthError( - HexagonAuthError.build(ACTION_SIGN_IN, ex) - ) - } + else -> { + if (error is AuthException.InvalidCredentialsException + && !hexagonTools.isGoogleSignInAvailable) { + // Note: If trying to sign-in with email already used with + // Google Sign-In on other device, fails to fall back to + // Google Sign-In because Play Services is not available. + errorMessage = getString(R.string.hexagon_signin_google_only) + } else { + errorMessage = error.message + Errors.logAndReportHexagonAuthError( + HexagonAuthError.build(ACTION_SIGN_IN, error) + ) } } - } else { - errorMessage = "Unknown error" - Errors.logAndReportHexagonAuthError( - HexagonAuthError(ACTION_SIGN_IN, errorMessage) - ) } changeAccount(null, errorMessage) @@ -241,27 +242,45 @@ class CloudSetupFragment : Fragment() { } } + private var authController: AuthFlowController? = null + + // FIXME private fun signIn() { - // Note: no need to provide a layout when just email sign-in is available - // as Firebase UI will just directly proceed without asking for the provider. - val authPickerLayout = AuthMethodPickerLayout.Builder(R.layout.auth_picker_email_google) - .setEmailButtonId(R.id.buttonAuthSignInEmail) - .setGoogleButtonId(R.id.buttonAuthSignInGoogle) - .build() - - // Create and launch sign-in intent - val intent = AuthUI.getInstance() - .createSignInIntentBuilder() - .setAvailableProviders(hexagonTools.firebaseSignInProviders) - .setIsSmartLockEnabled(hexagonTools.isGoogleSignInAvailable) - // AuthUI is not compatible with edge-to-edge on Android 15 (target SDK 35), - // so opt out its activities. - // https://github.com/firebase/FirebaseUI-Android/issues/2177 - .setTheme(R.style.Theme_SeriesGuide_DayNight_OptOutEdgeToEdge) - .setAuthMethodPickerLayout(authPickerLayout) - .build() + val authUI = FirebaseAuthUI.getInstance() + val configuration = authUIConfiguration { + context = requireContext().applicationContext + providers { + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + if (hexagonTools.isGoogleSignInAvailable) { + provider( + AuthProvider.Google( + scopes = listOf("email"), + // TODO Created by google-services plugin, but should define manually + serverClientId = requireContext().getString(R.string.default_web_client_id), + filterByAuthorizedAccounts = false + ) + ) + } + } + } + + val authController = authUI.createAuthFlow(configuration) + .also { this.authController = it } + val intent = authController.createIntent(requireContext()) signInWithFirebase.launch(intent) + +// // Note: no need to provide a layout when just email sign-in is available +// // as Firebase UI will just directly proceed without asking for the provider. +// val authPickerLayout = AuthMethodPickerLayout.Builder(R.layout.auth_picker_email_google) +// .setEmailButtonId(R.id.buttonAuthSignInEmail) +// .setGoogleButtonId(R.id.buttonAuthSignInGoogle) +// .build() } private fun signOut() { @@ -270,16 +289,17 @@ class CloudSetupFragment : Fragment() { hexagonTools.removeAccountAndSetDisabled() updateViews() } else { - setProgressVisible(true) - AuthUI.getInstance().signOut(requireContext()).addOnCompleteListener { - Timber.i("Signed out.") - signInAccount = null - hexagonTools.removeAccountAndSetDisabled() - if (this@CloudSetupFragment.isAdded) { - setProgressVisible(false) - updateViews() - } - } + // FIXME +// setProgressVisible(true) +// AuthUI.getInstance().signOut(requireContext()).addOnCompleteListener { +// Timber.i("Signed out.") +// signInAccount = null +// hexagonTools.removeAccountAndSetDisabled() +// if (this@CloudSetupFragment.isAdded) { +// setProgressVisible(false) +// updateViews() +// } +// } } } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt index d4ac563f0d..9f84cf0270 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonAuthError.kt @@ -3,7 +3,6 @@ package com.battlelancer.seriesguide.backend -import com.firebase.ui.auth.FirebaseUiException import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.CommonStatusCodes @@ -32,7 +31,8 @@ class HexagonAuthError( fun build(action: String, throwable: Throwable): HexagonAuthError { val message = throwable.message ?: "" val statusCode = when (throwable) { - is FirebaseUiException -> throwable.errorCode + // FIXME +// is FirebaseUiException -> throwable.errorCode is ApiException -> throwable.statusCode else -> null } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt index 2cd1ebb5df..a060923a91 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt @@ -13,7 +13,6 @@ import com.battlelancer.seriesguide.modules.ApplicationContext import com.battlelancer.seriesguide.util.Errors import com.battlelancer.seriesguide.util.Errors.Companion.logAndReportHexagon import com.battlelancer.seriesguide.util.isRetryError -import com.firebase.ui.auth.AuthUI import com.github.michaelbull.result.Err import com.github.michaelbull.result.Result import com.github.michaelbull.result.mapError @@ -58,17 +57,6 @@ class HexagonTools @Inject constructor( code != ConnectionResult.SERVICE_MISSING && code != ConnectionResult.SERVICE_INVALID } - val firebaseSignInProviders: List by lazy { - if (isGoogleSignInAvailable) { - listOf( - AuthUI.IdpConfig.EmailBuilder().build(), - AuthUI.IdpConfig.GoogleBuilder().build() - ) - } else { - listOf(AuthUI.IdpConfig.EmailBuilder().build()) - } - } - private val httpRequestInitializer by lazy { FirebaseHttpRequestInitializer() } private var lastSignInCheck: Long = 0 diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt index 3163b81d7c..a7051d75d3 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/RemoveCloudAccountDialogFragment.kt @@ -12,11 +12,7 @@ import androidx.appcompat.app.AppCompatDialogFragment import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.SgApp.Companion.getServicesComponent -import com.battlelancer.seriesguide.backend.RemoveCloudAccountDialogFragment.AccountRemovedEvent -import com.battlelancer.seriesguide.backend.RemoveCloudAccountDialogFragment.CanceledEvent import com.battlelancer.seriesguide.util.Errors -import com.firebase.ui.auth.AuthUI -import com.google.android.gms.tasks.Tasks import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -24,9 +20,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus -import timber.log.Timber import java.io.IOException -import java.util.concurrent.ExecutionException /** * Confirms whether to obliterate a SeriesGuide cloud account. If removal is tried, posts result as @@ -82,30 +76,31 @@ class RemoveCloudAccountDialogFragment : AppCompatDialogFragment() { return false } + // FIXME // Delete Firebase account so other clients are signed out as well - val task = AuthUI.getInstance().delete(context) - try { - Tasks.await(task) - } catch (e: Exception) { - // https://developers.google.com/android/reference/com/google/android/gms/tasks/Tasks#public-static-tresult-await-tasktresult-task - if (e is InterruptedException) { - // Do not report thread interruptions, it's expected. - Timber.w(e, ACTION_REMOVE_ACCOUNT) - } else { - val cause = if (e is ExecutionException) { - e.cause ?: e // The Task failed, getCause returns the original exception. - } else { - e // Unexpected exception. - } - val authEx = HexagonAuthError.build(ACTION_REMOVE_ACCOUNT, cause) - Errors.logAndReportHexagonAuthError(authEx) - } +// val task = AuthUI.getInstance().delete(context) +// try { +// Tasks.await(task) +// } catch (e: Exception) { +// // https://developers.google.com/android/reference/com/google/android/gms/tasks/Tasks#public-static-tresult-await-tasktresult-task +// if (e is InterruptedException) { +// // Do not report thread interruptions, it's expected. +// Timber.w(e, ACTION_REMOVE_ACCOUNT) +// } else { +// val cause = if (e is ExecutionException) { +// e.cause ?: e // The Task failed, getCause returns the original exception. +// } else { +// e // Unexpected exception. +// } +// val authEx = HexagonAuthError.build(ACTION_REMOVE_ACCOUNT, cause) +// Errors.logAndReportHexagonAuthError(authEx) +// } return false - } - - // disable Hexagon integration, remove local account data - hexagonTools.removeAccountAndSetDisabled() - return true +// } +// +// // disable Hexagon integration, remove local account data +// hexagonTools.removeAccountAndSetDisabled() +// return true } private fun onPostExecute(result: Boolean) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt index b194f33cfb..793cec7ab8 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt @@ -72,7 +72,8 @@ abstract class AuthException( * The provided credentials are not valid. * * This exception is thrown when the user provides incorrect login information, - * such as wrong email/password combinations or malformed credentials. + * such as wrong email/password combinations or the account requires a different type of + * credentials (such as Google Sign-In). * * @property message The detailed error message * @property cause The underlying [Throwable] that caused this exception From 1a212da80a599cd8a480f948ab5d955c86938186 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 26 Feb 2026 15:24:21 +0100 Subject: [PATCH 010/150] Auth: fix imports --- .../seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 0921c8b037..93619808eb 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -48,9 +48,9 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.battlelancer.seriesguide.BuildConfig import com.battlelancer.seriesguide.backend.auth.AuthException import com.battlelancer.seriesguide.backend.auth.AuthState -import com.battlelancer.seriesguide.backend.auth.BuildConfig import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration From 1c966f182ca9a4cceb817cf12e0f70863a6b9260 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 10:48:04 +0100 Subject: [PATCH 011/150] Auth: register FirebaseAuthActivity in AndroidManifest --- app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 68ccb2cc88..7872917b3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,8 @@ + Date: Thu, 26 Feb 2026 15:44:49 +0100 Subject: [PATCH 012/150] Auth: remove unused providers --- .../seriesguide/backend/auth/AuthState.kt | 73 +-- .../backend/auth/FirebaseAuthUI.kt | 8 +- .../auth/FirebaseUIComposeRegistrar.kt | 29 - .../auth/configuration/AuthUIConfiguration.kt | 8 +- .../AnonymousAuthProvider+FirebaseAuthUI.kt | 135 ----- .../auth_provider/AuthProvider.kt | 537 +----------------- .../FacebookAuthProvider+FirebaseAuthUI.kt | 224 -------- .../PhoneAuthProvider+FirebaseAuthUI.kt | 347 ----------- .../string_provider/AuthUIStringProvider.kt | 111 +--- .../AuthUIStringProviderSample.kt | 51 -- .../DefaultAuthUIStringProvider.kt | 80 +-- .../theme/ProviderStyleDefaults.kt | 61 +- .../validators/PhoneNumberValidator.kt | 59 -- .../auth/ui/components/AuthProviderButton.kt | 137 +---- .../auth/ui/components/CountrySelector.kt | 210 ------- .../auth/ui/method_picker/AuthMethodPicker.kt | 30 +- .../auth/ui/screens/FirebaseAuthScreen.kt | 111 ---- .../ui/screens/phone/EnterPhoneNumberUI.kt | 187 ------ .../screens/phone/EnterVerificationCodeUI.kt | 218 ------- .../auth/ui/screens/phone/PhoneAuthScreen.kt | 379 ------------ .../auth/util/EmailLinkPersistenceManager.kt | 4 +- .../backend/auth/util/PhoneNumberUtils.kt | 65 --- .../backend/auth/util/ProviderAvailability.kt | 31 - app/src/main/res/values/strings.xml | 28 - 24 files changed, 39 insertions(+), 3084 deletions(-) delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt index 6feca97c20..38c9b49a5f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthState.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -11,8 +12,6 @@ import com.battlelancer.seriesguide.backend.auth.AuthState.Companion.Idle import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorResolver -import com.google.firebase.auth.PhoneAuthCredential -import com.google.firebase.auth.PhoneAuthProvider /** * Represents the authentication state in Firebase Auth UI. @@ -218,74 +217,6 @@ abstract class AuthState private constructor() { override fun toString(): String = "AuthState.EmailSignInLinkSent" } - /** - * Phone number was automatically verified via SMS instant verification. - * - * This state is emitted when Firebase Phone Authentication successfully retrieves - * and verifies the SMS code automatically without user interaction. This happens - * when Google Play services can detect the incoming SMS message. - * - * @property credential The [PhoneAuthCredential] that can be used to sign in the user - * - * @see PhoneNumberVerificationRequired for the manual verification flow - */ - class SMSAutoVerified(val credential: PhoneAuthCredential) : AuthState() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is SMSAutoVerified) return false - return credential == other.credential - } - - override fun hashCode(): Int { - var result = credential.hashCode() - result = 31 * result + credential.hashCode() - return result - } - - override fun toString(): String = - "AuthState.SMSAutoVerified(credential=$credential)" - } - - /** - * Phone number verification requires manual code entry. - * - * This state is emitted when Firebase Phone Authentication cannot instantly verify - * the phone number and sends an SMS code that the user must manually enter. This is - * the normal flow when automatic SMS retrieval is not available or fails. - * - * **Resending codes:** - * To allow users to resend the verification code (if they didn't receive it), - * call [FirebaseAuthUI.verifyPhoneNumber] again with: - * - `isForceResendingTokenEnabled = true` - * - `forceResendingToken` from this state - * - * @property verificationId The verification ID to use when submitting the code. - * This must be passed to [FirebaseAuthUI.submitVerificationCode]. - * @property forceResendingToken Token that can be used to resend the SMS code if needed - * - */ - class PhoneNumberVerificationRequired( - val verificationId: String, - val forceResendingToken: PhoneAuthProvider.ForceResendingToken, - ) : AuthState() { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is PhoneNumberVerificationRequired) return false - return verificationId == other.verificationId && - forceResendingToken == other.forceResendingToken - } - - override fun hashCode(): Int { - var result = verificationId.hashCode() - result = 31 * result + forceResendingToken.hashCode() - return result - } - - override fun toString(): String = - "AuthState.PhoneNumberVerificationRequired(verificationId=$verificationId, " + - "forceResendingToken=$forceResendingToken)" - } - companion object { /** * Creates an Idle state instance. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt index d7c041bd3f..2ad32f60ca 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseAuthUI.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -11,7 +12,6 @@ import android.content.Intent import androidx.annotation.RestrictTo import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signOutFromFacebook import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signOutFromGoogle import com.google.firebase.Firebase import com.google.firebase.FirebaseApp @@ -70,9 +70,6 @@ class FirebaseAuthUI private constructor( @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) var testCredentialManagerProvider: AuthProvider.Google.CredentialManagerProvider? = null - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - var testLoginManagerProvider: AuthProvider.Facebook.LoginManagerProvider? = null - /** * Checks whether a user is currently signed in. * @@ -363,7 +360,6 @@ class FirebaseAuthUI private constructor( auth.signOut() .also { signOutFromGoogle(context) - signOutFromFacebook() } // Update state to idle (user signed out) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt deleted file mode 100644 index cb2f9b6d6b..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/FirebaseUIComposeRegistrar.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth - -import android.util.Log -import androidx.annotation.Keep -import com.google.firebase.components.Component -import com.google.firebase.components.ComponentRegistrar -import com.google.firebase.platforminfo.LibraryVersionComponent - -/** - * Registers the FirebaseUI-Android Compose library with Firebase Analytics. - * This enables Firebase to track which versions of FirebaseUI are being used. - */ -@Keep -class FirebaseUIComposeRegistrar : ComponentRegistrar { - override fun getComponents(): List> { - Log.d("FirebaseUIRegistrar", "FirebaseUI Compose Registrar initialized: " + - "LIBRARY_NAME: ${BuildConfig.LIBRARY_NAME}, " + - "VERSION_NAME: ${BuildConfig.VERSION_NAME}") - return listOf( - LibraryVersionComponent.create(BuildConfig.LIBRARY_NAME, BuildConfig.VERSION_NAME) - ) - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt index c5da082656..042cf8c57c 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/AuthUIConfiguration.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -64,9 +65,6 @@ class AuthUIConfigurationBuilder { "Unknown providers: ${unknownProviders.joinToString { it.providerId }}" } - // Cannot have only anonymous provider - AuthProvider.Anonymous.validate(providers) - // Check for duplicate providers val providerIds = providers.map { it.providerId } val duplicates = providerIds.groupingBy { it }.eachCount().filter { it.value > 1 } @@ -82,9 +80,7 @@ class AuthUIConfigurationBuilder { providers.forEach { provider -> when (provider) { is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled) - is AuthProvider.Phone -> provider.validate() is AuthProvider.Google -> provider.validate(context) - is AuthProvider.Facebook -> provider.validate(context) is AuthProvider.GenericOAuth -> provider.validate() else -> null } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt deleted file mode 100644 index ffab1ad2b8..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import com.battlelancer.seriesguide.backend.auth.AuthException -import com.battlelancer.seriesguide.backend.auth.AuthState -import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await - -/** - * Creates a remembered launcher function for anonymous sign-in. - * - * @return A launcher function that starts the anonymous sign-in flow when invoked - * - * @see signInAnonymously - * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts - */ -@Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(): () -> Unit { - val coroutineScope = rememberCoroutineScope() - return remember(this) { - { - coroutineScope.launch { - try { - signInAnonymously() - } catch (e: AuthException) { - // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - } - } - } - } -} - -/** - * Signs in a user anonymously with Firebase Authentication. - * - * This method creates a temporary anonymous user account that can be used for testing - * or as a starting point for users who want to try the app before creating a permanent - * account. Anonymous users can later be upgraded to permanent accounts by linking - * credentials (email/password, social providers, phone, etc.). - * - * **Flow:** - * 1. Updates auth state to loading with "Signing in anonymously..." message - * 2. Calls Firebase Auth's `signInAnonymously()` method - * 3. Updates auth state to idle on success - * 4. Handles cancellation and converts exceptions to [AuthException] types - * - * **Anonymous Account Benefits:** - * - No user data collection required - * - Immediate access to app features - * - Can be upgraded to permanent account later - * - Useful for guest users and app trials - * - * **Account Upgrade:** - * Anonymous accounts can be upgraded to permanent accounts by calling methods like: - * - [signInAndLinkWithCredential] with email/password or social credentials - * - [createOrLinkUserWithEmailAndPassword] for email/password accounts - * - [signInWithPhoneAuthCredential] for phone authentication - * - * **Example: Basic anonymous sign-in** - * ```kotlin - * try { - * firebaseAuthUI.signInAnonymously() - * // User is now signed in anonymously - * // Show app content or prompt for account creation - * } catch (e: AuthException.AuthCancelledException) { - * // User cancelled the sign-in process - * } catch (e: AuthException.NetworkException) { - * // Network error occurred - * } - * ``` - * - * **Example: Anonymous sign-in with upgrade flow** - * ```kotlin - * // Step 1: Sign in anonymously - * firebaseAuthUI.signInAnonymously() - * - * // Step 2: Later, upgrade to permanent account - * try { - * firebaseAuthUI.createOrLinkUserWithEmailAndPassword( - * context = context, - * config = authUIConfig, - * provider = emailProvider, - * name = "John Doe", - * email = "john@example.com", - * password = "SecurePass123!" - * ) - * // Anonymous account upgraded to permanent email/password account - * } catch (e: AuthException.AccountLinkingRequiredException) { - * // Email already exists - show account linking UI - * } - * ``` - * - * @throws AuthException.AuthCancelledException if the coroutine is cancelled - * @throws AuthException.NetworkException if a network error occurs - * @throws AuthException.UnknownException for other authentication errors - * - * @see signInAndLinkWithCredential for upgrading anonymous accounts - * @see createOrLinkUserWithEmailAndPassword for email/password upgrade - * @see signInWithPhoneAuthCredential for phone authentication upgrade - */ -internal suspend fun FirebaseAuthUI.signInAnonymously() { - try { - updateAuthState(AuthState.Loading("Signing in anonymously...")) - auth.signInAnonymously().await() - updateAuthState(AuthState.Idle) - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Sign in anonymously was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt index 5d11c8824e..c902c7c2e9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt @@ -1,58 +1,41 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider -import android.app.Activity import android.content.Context import android.net.Uri import android.util.Log import androidx.annotation.RestrictTo import androidx.compose.ui.graphics.Color -import androidx.core.net.toUri import androidx.credentials.ClearCredentialStateRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.datastore.preferences.core.stringPreferencesKey -import com.facebook.AccessToken import com.firebase.ui.auth.R import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfigurationDsl import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset import com.battlelancer.seriesguide.backend.auth.util.ContinueUrlBuilder -import com.battlelancer.seriesguide.backend.auth.util.PhoneNumberUtils import com.battlelancer.seriesguide.backend.auth.util.Preconditions -import com.battlelancer.seriesguide.backend.auth.util.ProviderAvailability import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.common.api.Scope import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential -import com.google.firebase.FirebaseException import com.google.firebase.auth.ActionCodeSettings import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.EmailAuthProvider -import com.google.firebase.auth.FacebookAuthProvider import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.GithubAuthProvider import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.auth.MultiFactorSession -import com.google.firebase.auth.PhoneAuthCredential -import com.google.firebase.auth.PhoneAuthOptions -import com.google.firebase.auth.PhoneAuthProvider -import com.google.firebase.auth.TwitterAuthProvider import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.actionCodeSettings -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await -import java.util.concurrent.TimeUnit -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine @AuthUIConfigurationDsl class AuthProvidersBuilder { @@ -74,15 +57,7 @@ internal enum class Provider( val isSocialProvider: Boolean = false, ) { GOOGLE(GoogleAuthProvider.PROVIDER_ID, providerName = "Google", isSocialProvider = true), - FACEBOOK(FacebookAuthProvider.PROVIDER_ID, providerName = "Facebook", isSocialProvider = true), - TWITTER(TwitterAuthProvider.PROVIDER_ID, providerName = "Twitter", isSocialProvider = true), - GITHUB(GithubAuthProvider.PROVIDER_ID, providerName = "Github", isSocialProvider = true), - EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"), - PHONE(PhoneAuthProvider.PROVIDER_ID, providerName = "Phone"), - ANONYMOUS("anonymous", providerName = "Anonymous"), - MICROSOFT("microsoft.com", providerName = "Microsoft", isSocialProvider = true), - YAHOO("yahoo.com", providerName = "Yahoo", isSocialProvider = true), - APPLE("apple.com", providerName = "Apple", isSocialProvider = true); + EMAIL(EmailAuthProvider.PROVIDER_ID, providerName = "Email"); companion object { fun fromId(id: String?): Provider? { @@ -244,227 +219,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName: } } - /** - * Phone number authentication provider configuration. - */ - class Phone( - /** - * The phone number in international format. - */ - val defaultNumber: String?, - - /** - * The default country code to pre-select. - */ - val defaultCountryCode: String?, - - /** - * A list of allowed country codes. - */ - val allowedCountries: List?, - - /** - * The expected length of the SMS verification code. Defaults to 6. - */ - val smsCodeLength: Int = 6, - - /** - * The timeout in seconds for receiving the SMS. Defaults to 60L. - */ - val timeout: Long = 60L, - - /** - * Enables instant verification of the phone number. Defaults to true. - */ - val isInstantVerificationEnabled: Boolean = true, - ) : AuthProvider(providerId = Provider.PHONE.id, providerName = Provider.PHONE.providerName) { - /** - * Sealed class representing the result of phone number verification. - * - * Phone verification can complete in two ways: - * - [AutoVerified]: SMS was instantly retrieved and verified by the Firebase SDK - * - [NeedsManualVerification]: SMS code was sent, user must manually enter it - */ - internal sealed class VerifyPhoneNumberResult { - /** - * Instant verification succeeded via SMS auto-retrieval. - * - * @property credential The [PhoneAuthCredential] that can be used to sign in - */ - class AutoVerified(val credential: PhoneAuthCredential) : VerifyPhoneNumberResult() - - /** - * Instant verification failed, manual code entry required. - * - * @property verificationId The verification ID to use when submitting the code - * @property token Token for resending the verification code - */ - class NeedsManualVerification( - val verificationId: String, - val token: PhoneAuthProvider.ForceResendingToken, - ) : VerifyPhoneNumberResult() - } - - internal fun validate() { - defaultNumber?.let { - check(PhoneNumberUtils.isValid(it)) { - "Invalid phone number: $it" - } - } - - defaultCountryCode?.let { - check(PhoneNumberUtils.isValidIso(it)) { - "Invalid country iso: $it" - } - } - - allowedCountries?.forEach { code -> - check(PhoneNumberUtils.isValidIso(code)) { - "Invalid input: You must provide a valid country iso (alpha-2) " + - "or code (e-164). e.g. 'us' or '+1'. Invalid code: $code" - } - } - } - - /** - * Internal coroutine-based wrapper for Firebase Phone Authentication verification. - * - * This method wraps the callback-based Firebase Phone Auth API into a suspending function - * using Kotlin coroutines. It handles the Firebase [PhoneAuthProvider.OnVerificationStateChangedCallbacks] - * and converts them into a [VerifyPhoneNumberResult]. - * - * **Callback mapping:** - * - `onVerificationCompleted` → [VerifyPhoneNumberResult.AutoVerified] - * - `onCodeSent` → [VerifyPhoneNumberResult.NeedsManualVerification] - * - `onVerificationFailed` → throws the exception - * - * This is a private helper method used by [verifyPhoneNumber]. Callers should use - * [verifyPhoneNumber] instead as it handles state management and error handling. - * - * @param auth The [FirebaseAuth] instance to use for verification - * @param phoneNumber The phone number to verify in E.164 format - * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, - * Firebase verifies the phone number for enrolling as a second authentication factor - * instead of primary sign-in. Pass null for standard phone authentication. - * @param forceResendingToken Optional token from previous verification for resending - * - * @return [VerifyPhoneNumberResult] indicating auto-verified or manual verification needed - * @throws FirebaseException if verification fails - */ - internal suspend fun verifyPhoneNumberAwait( - auth: FirebaseAuth, - activity: Activity?, - phoneNumber: String, - multiFactorSession: MultiFactorSession? = null, - forceResendingToken: PhoneAuthProvider.ForceResendingToken?, - verifier: Verifier = DefaultVerifier(), - ): VerifyPhoneNumberResult { - return verifier.verifyPhoneNumber( - auth, - activity, - phoneNumber, - timeout, - forceResendingToken, - multiFactorSession, - isInstantVerificationEnabled - ) - } - - /** - * @suppress - */ - internal interface Verifier { - suspend fun verifyPhoneNumber( - auth: FirebaseAuth, - activity: Activity?, - phoneNumber: String, - timeout: Long, - forceResendingToken: PhoneAuthProvider.ForceResendingToken?, - multiFactorSession: MultiFactorSession?, - isInstantVerificationEnabled: Boolean, - ): VerifyPhoneNumberResult - } - - /** - * @suppress - */ - internal class DefaultVerifier : Verifier { - override suspend fun verifyPhoneNumber( - auth: FirebaseAuth, - activity: Activity?, - phoneNumber: String, - timeout: Long, - forceResendingToken: PhoneAuthProvider.ForceResendingToken?, - multiFactorSession: MultiFactorSession?, - isInstantVerificationEnabled: Boolean, - ): VerifyPhoneNumberResult { - return suspendCoroutine { continuation -> - val options = PhoneAuthOptions.newBuilder(auth) - .setPhoneNumber(phoneNumber) - .requireSmsValidation(!isInstantVerificationEnabled) - .setTimeout(timeout, TimeUnit.SECONDS) - .setCallbacks(object : - PhoneAuthProvider.OnVerificationStateChangedCallbacks() { - override fun onVerificationCompleted(credential: PhoneAuthCredential) { - continuation.resume(VerifyPhoneNumberResult.AutoVerified(credential)) - } - - override fun onVerificationFailed(e: FirebaseException) { - continuation.resumeWithException(e) - } - - override fun onCodeSent( - verificationId: String, - token: PhoneAuthProvider.ForceResendingToken, - ) { - continuation.resume( - VerifyPhoneNumberResult.NeedsManualVerification( - verificationId, - token - ) - ) - } - }) - .apply { - activity?.let { - setActivity(it) - } - forceResendingToken?.let { - setForceResendingToken(it) - } - multiFactorSession?.let { - setMultiFactorSession(it) - } - } - .build() - PhoneAuthProvider.verifyPhoneNumber(options) - } - } - } - - /** - * An interface to wrap the static `PhoneAuthProvider.getCredential` method to make it testable. - * @suppress - */ - internal interface CredentialProvider { - fun getCredential(verificationId: String, smsCode: String): PhoneAuthCredential - } - - /** - * The default implementation of [CredentialProvider] that calls the static method. - * @suppress - */ - internal class DefaultCredentialProvider : CredentialProvider { - override fun getCredential( - verificationId: String, - smsCode: String, - ): PhoneAuthCredential { - return PhoneAuthProvider.getCredential(verificationId, smsCode) - } - } - - } - /** * Google Sign-In provider configuration. */ @@ -629,291 +383,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName: } } - /** - * Facebook Login provider configuration. - */ - class Facebook( - /** - * The list of scopes (permissions) to request. Defaults to email and public_profile. - */ - override val scopes: List = listOf("email", "public_profile"), - - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map = emptyMap(), - ) : OAuth( - providerId = Provider.FACEBOOK.id, - providerName = Provider.FACEBOOK.providerName, - scopes = scopes, - customParameters = customParameters - ) { - internal fun validate(context: Context) { - if (!ProviderAvailability.IS_FACEBOOK_AVAILABLE) { - throw RuntimeException( - "Facebook provider cannot be configured " + - "without dependency. Did you forget to add " + - "'com.facebook.android:facebook-login:VERSION' dependency?" - ) - } - - Preconditions.checkConfigured( - context, - "Facebook provider unconfigured. Make sure to " + - "add a `facebook_application_id` string to your strings.xml", - R.string.facebook_application_id - ) - - Preconditions.checkConfigured( - context, - "Facebook provider unconfigured. Make sure to " + - "add a `facebook_login_protocol_scheme` string to your strings.xml", - R.string.facebook_login_protocol_scheme - ) - - Preconditions.checkConfigured( - context, - "Facebook provider unconfigured. Make sure to " + - "add a `facebook_client_token` string to your strings.xml", - R.string.facebook_client_token - ) - } - - /** - * An interface to wrap Facebook LoginManager and credential operations to make them testable. - * @suppress - */ - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - interface LoginManagerProvider { - fun getCredential(token: String): AuthCredential - fun logOut() - } - - /** - * The default implementation of [LoginManagerProvider]. - * @suppress - */ - @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - class DefaultLoginManagerProvider : LoginManagerProvider { - override fun getCredential(token: String): AuthCredential { - return FacebookAuthProvider.getCredential(token) - } - - override fun logOut() { - com.facebook.login.LoginManager.getInstance().logOut() - } - } - - /** - * Internal data class to hold Facebook profile information. - */ - internal class FacebookProfileData( - val displayName: String?, - val email: String?, - val photoUrl: Uri?, - ) - - /** - * Fetches user profile data from Facebook Graph API. - * - * @param accessToken The Facebook access token - * @return FacebookProfileData containing user's display name, email, and photo URL - */ - internal suspend fun fetchFacebookProfile(accessToken: AccessToken): FacebookProfileData? { - return suspendCancellableCoroutine { continuation -> - val request = - com.facebook.GraphRequest.newMeRequest(accessToken) { jsonObject, response -> - try { - val error = response?.error - if (error != null) { - Log.e( - "FirebaseAuthUI.signInWithFacebook", - "Graph API error: ${error.errorMessage}" - ) - continuation.resume(null) - return@newMeRequest - } - - if (jsonObject == null) { - Log.e( - "FirebaseAuthUI.signInWithFacebook", - "Graph API returned null response" - ) - continuation.resume(null) - return@newMeRequest - } - - val name = jsonObject.optString("name") - val email = jsonObject.optString("email") - - // Extract photo URL from picture object - val photoUrl = try { - jsonObject.optJSONObject("picture") - ?.optJSONObject("data") - ?.optString("url") - ?.takeIf { it.isNotEmpty() }?.toUri() - } catch (e: Exception) { - Log.w( - "FirebaseAuthUI.signInWithFacebook", - "Error parsing photo URL", - e - ) - null - } - - Log.d( - "FirebaseAuthUI.signInWithFacebook", - "Profile fetched: name=$name, email=$email, hasPhoto=${photoUrl != null}" - ) - - continuation.resume( - FacebookProfileData( - displayName = name, - email = email, - photoUrl = photoUrl - ) - ) - } catch (e: Exception) { - Log.e( - "FirebaseAuthUI.signInWithFacebook", - "Error processing Graph API response", - e - ) - continuation.resume(null) - } - } - - // Request specific fields: id, name, email, and picture - val parameters = android.os.Bundle().apply { - putString("fields", "id,name,email,picture") - } - request.parameters = parameters - request.executeAsync() - } - } - } - - /** - * Twitter/X authentication provider configuration. - */ - class Twitter( - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map, - ) : OAuth( - providerId = Provider.TWITTER.id, - providerName = Provider.TWITTER.providerName, - customParameters = customParameters - ) - - /** - * Github authentication provider configuration. - */ - class Github( - /** - * The list of scopes to request. Defaults to user:email. - */ - override val scopes: List = listOf("user:email"), - - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map, - ) : OAuth( - providerId = Provider.GITHUB.id, - providerName = Provider.GITHUB.providerName, - scopes = scopes, - customParameters = customParameters - ) - - /** - * Microsoft authentication provider configuration. - */ - class Microsoft( - /** - * The list of scopes to request. Defaults to openid, profile, email. - */ - override val scopes: List = listOf("openid", "profile", "email"), - - /** - * The tenant ID for Azure Active Directory. - */ - val tenant: String?, - - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map, - ) : OAuth( - providerId = Provider.MICROSOFT.id, - providerName = Provider.MICROSOFT.providerName, - scopes = scopes, - customParameters = customParameters - ) - - /** - * Yahoo authentication provider configuration. - */ - class Yahoo( - /** - * The list of scopes to request. Defaults to openid, profile, email. - */ - override val scopes: List = listOf("openid", "profile", "email"), - - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map, - ) : OAuth( - providerId = Provider.YAHOO.id, - providerName = Provider.YAHOO.providerName, - scopes = scopes, - customParameters = customParameters - ) - - /** - * Apple Sign-In provider configuration. - */ - class Apple( - /** - * The list of scopes to request. Defaults to name and email. - */ - override val scopes: List = listOf("name", "email"), - - /** - * The locale for the sign-in page. - */ - val locale: String?, - - /** - * A map of custom OAuth parameters. - */ - override val customParameters: Map, - ) : OAuth( - providerId = Provider.APPLE.id, - providerName = Provider.APPLE.providerName, - scopes = scopes, - customParameters = customParameters - ) - - /** - * Anonymous authentication provider. It has no configurable properties. - */ - object Anonymous : AuthProvider( - providerId = Provider.ANONYMOUS.id, - providerName = Provider.ANONYMOUS.providerName - ) { - internal fun validate(providers: List) { - if (providers.size == 1 && providers.first() is Anonymous) { - throw IllegalStateException( - "Sign in as guest cannot be the only sign in method. " + - "In this case, sign the user in anonymously your self; no UI is needed." - ) - } - } - } - /** * A generic OAuth provider for any unsupported provider. */ diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt deleted file mode 100644 index defe7c81f1..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ /dev/null @@ -1,224 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider - -import android.content.Context -import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import com.facebook.AccessToken -import com.facebook.CallbackManager -import com.facebook.FacebookCallback -import com.facebook.FacebookException -import com.facebook.login.LoginManager -import com.facebook.login.LoginResult -import com.battlelancer.seriesguide.backend.auth.AuthException -import com.battlelancer.seriesguide.backend.auth.AuthState -import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager -import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch - -/** - * Creates a remembered launcher function for Facebook sign-in. - * - * Returns a launcher function that initiates the Facebook sign-in flow. Automatically handles - * profile data fetching, Firebase credential creation, anonymous account upgrades, and account - * linking when an email collision occurs. - * - * @param context Android context for DataStore access when saving credentials for linking - * @param config The [AuthUIConfiguration] containing authentication settings - * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider - * - * @return A launcher function that starts the Facebook sign-in flow when invoked - * - * @see signInWithFacebook - */ -@Composable -internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( - context: Context, - config: AuthUIConfiguration, - provider: AuthProvider.Facebook, -): () -> Unit { - val coroutineScope = rememberCoroutineScope() - val callbackManager = remember { CallbackManager.Factory.create() } - val loginManager = LoginManager.getInstance() - - val launcher = rememberLauncherForActivityResult( - loginManager.createLogInActivityResultContract( - callbackManager, - null - ), - onResult = {}, - ) - - DisposableEffect(Unit) { - loginManager.registerCallback( - callbackManager, - object : FacebookCallback { - override fun onSuccess(result: LoginResult) { - coroutineScope.launch { - try { - signInWithFacebook( - context = context, - config = config, - provider = provider, - accessToken = result.accessToken, - ) - } catch (e: AuthException) { - // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - } - } - } - - override fun onCancel() { - updateAuthState(AuthState.Idle) - } - - override fun onError(error: FacebookException) { - Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error) - updateAuthState( - AuthState.Error( - authException - ) - ) - } - }) - - onDispose { loginManager.unregisterCallback(callbackManager) } - } - - return { - updateAuthState( - AuthState.Loading("Signing in with facebook...") - ) - launcher.launch(provider.scopes) - } -} - -/** - * Signs in a user with Facebook by converting a Facebook access token to a Firebase credential. - * - * Fetches user profile data from Facebook Graph API, creates a Firebase credential, and signs in - * or upgrades an anonymous account. Handles account collisions by saving the Facebook credential - * for linking and throwing [AuthException.AccountLinkingRequiredException]. - * - * @param context Android context for DataStore access when saving credentials for linking - * @param config The [AuthUIConfiguration] containing authentication settings - * @param provider The [AuthProvider.Facebook] configuration - * @param accessToken The Facebook [AccessToken] from successful login - * @param credentialProvider Creates Firebase credentials from Facebook tokens - * - * @throws AuthException.AccountLinkingRequiredException if an account exists with the same email - * @throws AuthException.AuthCancelledException if the coroutine is cancelled - * @throws AuthException.NetworkException if a network error occurs - * @throws AuthException.InvalidCredentialsException if the Facebook token is invalid - * - * @see rememberSignInWithFacebookLauncher - * @see signInAndLinkWithCredential - */ -internal suspend fun FirebaseAuthUI.signInWithFacebook( - context: Context, - config: AuthUIConfiguration, - provider: AuthProvider.Facebook, - accessToken: AccessToken, - credentialProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), -) { - try { - updateAuthState( - AuthState.Loading("Signing in with facebook...") - ) - val profileData = provider.fetchFacebookProfile(accessToken) - val credential = credentialProvider.getCredential(accessToken.token) - signInAndLinkWithCredential( - config = config, - credential = credential, - provider = provider, - displayName = profileData?.displayName, - photoUrl = profileData?.photoUrl, - ) - - // Save sign-in preference for "Continue as..." feature - try { - val user = auth.currentUser - val identifier = user?.email - if (identifier != null) { - SignInPreferenceManager.saveLastSignIn( - context = context, - providerId = provider.providerId, - identifier = identifier - ) - android.util.Log.d("FacebookAuthProvider", "Sign-in preference saved for: $identifier") - } - } catch (e: Exception) { - // Failed to save preference - log but don't break auth flow - android.util.Log.w("FacebookAuthProvider", "Failed to save sign-in preference", e) - } - } catch (e: AuthException.AccountLinkingRequiredException) { - // Account collision occurred - save Facebook credential for linking after email link sign-in - // This happens when a user tries to sign in with Facebook but an email link account exists - EmailLinkPersistenceManager.default.saveCredentialForLinking( - context = context, - providerType = provider.providerId, - idToken = null, - accessToken = accessToken.token - ) - - // Re-throw to let UI handle the account linking flow - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: FacebookException) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Sign in with facebook was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } -} - -/** - * Signs out the current user from Facebook. - * - * Invokes Facebook's LoginManager to log out the user from their Facebook session. - * This method silently catches and ignores any exceptions that may occur during the - * logout process to ensure the sign-out flow continues even if Facebook logout fails. - * - * This is typically called as part of the overall sign-out flow when a user signs out - * from Firebase Authentication. - */ -internal fun FirebaseAuthUI.signOutFromFacebook( - loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), -) { - try { - if (Provider.fromId(getCurrentUser()?.providerId) != Provider.FACEBOOK) return - (testLoginManagerProvider ?: loginManagerProvider).logOut() - } catch (e: Exception) { - Log.e("FacebookAuthProvider", "Error during Facebook sign out", e) - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt deleted file mode 100644 index 1ce562a087..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/PhoneAuthProvider+FirebaseAuthUI.kt +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.configuration.auth_provider - -import android.app.Activity -import android.content.Context -import com.battlelancer.seriesguide.backend.auth.AuthException -import com.battlelancer.seriesguide.backend.auth.AuthState -import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager -import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.MultiFactorSession -import com.google.firebase.auth.PhoneAuthCredential -import com.google.firebase.auth.PhoneAuthProvider -import kotlinx.coroutines.CancellationException - -/** - * Initiates phone number verification with Firebase Phone Authentication. - * - * This method starts the phone verification flow, which can complete in two ways: - * 1. **Instant verification** (auto): Firebase SDK automatically retrieves and verifies - * the SMS code without user interaction. This happens when Google Play services can - * detect the incoming SMS automatically. - * 2. **Manual verification**: SMS code is sent to the user's device, and the user must - * manually enter the code via [submitVerificationCode]. - * - * **Flow:** - * - Call this method with the phone number - * - Firebase SDK attempts instant verification - * - If instant verification succeeds: - * - Emits [AuthState.SMSAutoVerified] with the credential - * - UI should observe this state and call [signInWithPhoneAuthCredential] - * - If instant verification fails: - * - Emits [AuthState.PhoneNumberVerificationRequired] with verification details - * - UI should show code entry screen - * - User enters code → call [submitVerificationCode] - * - * **Resending codes:** - * To resend a verification code, call this method again with: - * - `forceResendingToken` = the token from [AuthState.PhoneNumberVerificationRequired] - * - * **Example: Basic phone verification** - * ```kotlin - * // Step 1: Start verification - * firebaseAuthUI.verifyPhoneNumber( - * provider = phoneProvider, - * phoneNumber = "+1234567890", - * ) - * - * // Step 2: Observe AuthState - * authUI.authStateFlow().collect { state -> - * when (state) { - * is AuthState.SMSAutoVerified -> { - * // Instant verification succeeded! - * showToast("Phone number verified automatically") - * // Now sign in with the credential - * firebaseAuthUI.signInWithPhoneAuthCredential( - * config = authUIConfig, - * credential = state.credential - * ) - * } - * is AuthState.PhoneNumberVerificationRequired -> { - * // Show code entry screen - * showCodeEntryScreen( - * verificationId = state.verificationId, - * forceResendingToken = state.forceResendingToken - * ) - * } - * is AuthState.Error -> { - * // Handle error - * showError(state.exception.message) - * } - * } - * } - * - * // Step 3: When user enters code - * firebaseAuthUI.submitVerificationCode( - * config = authUIConfig, - * verificationId = verificationId, - * code = userEnteredCode - * ) - * ``` - * - * **Example: Resending verification code** - * ```kotlin - * // User didn't receive the code, wants to resend - * firebaseAuthUI.verifyPhoneNumber( - * provider = phoneProvider, - * phoneNumber = "+1234567890", - * forceResendingToken = savedToken // From PhoneNumberVerificationRequired state - * ) - * ``` - * - * @param provider The [AuthProvider.Phone] configuration containing timeout and other settings - * @param phoneNumber The phone number to verify in E.164 format (e.g., "+1234567890") - * @param multiFactorSession Optional [MultiFactorSession] for MFA enrollment. When provided, - * this initiates phone verification for enrolling a second factor rather than primary sign-in. - * Obtain this from `FirebaseUser.multiFactor.session` when enrolling MFA. - * @param forceResendingToken Optional token from previous verification for resending SMS - * - * @throws AuthException.InvalidCredentialsException if the phone number is invalid - * @throws AuthException.TooManyRequestsException if SMS quota is exceeded - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs - */ -internal suspend fun FirebaseAuthUI.verifyPhoneNumber( - provider: AuthProvider.Phone, - activity: Activity?, - phoneNumber: String, - multiFactorSession: MultiFactorSession? = null, - forceResendingToken: PhoneAuthProvider.ForceResendingToken? = null, - verifier: AuthProvider.Phone.Verifier = AuthProvider.Phone.DefaultVerifier(), -) { - try { - updateAuthState(AuthState.Loading("Verifying phone number...")) - val result = provider.verifyPhoneNumberAwait( - auth = auth, - activity = activity, - phoneNumber = phoneNumber, - multiFactorSession = multiFactorSession, - forceResendingToken = forceResendingToken, - verifier = verifier - ) - when (result) { - is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - updateAuthState(AuthState.SMSAutoVerified(credential = result.credential)) - } - - is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - updateAuthState( - AuthState.PhoneNumberVerificationRequired( - verificationId = result.verificationId, - forceResendingToken = result.token, - ) - ) - } - } - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Verify phone number was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } -} - -/** - * Submits a verification code entered by the user and signs them in. - * - * This method is called after [verifyPhoneNumber] emits [AuthState.PhoneNumberVerificationRequired], - * indicating that manual code entry is needed. It creates a [PhoneAuthCredential] from the - * verification ID and user-entered code, then signs in the user by calling - * [signInWithPhoneAuthCredential]. - * - * **Flow:** - * 1. User receives SMS with 6-digit code - * 2. User enters code in UI - * 3. UI calls this method with the code - * 4. Credential is created and used to sign in - * 5. Returns [AuthResult] with signed-in user - * - * This method handles both normal sign-in and anonymous account upgrade scenarios based - * on the [AuthUIConfiguration] settings. - * - * **Example: Manual code entry flow* - * ``` - * val userEnteredCode = "123456" - * try { - * val result = firebaseAuthUI.submitVerificationCode( - * config = authUIConfig, - * verificationId = savedVerificationId!!, - * code = userEnteredCode - * ) - * // User is now signed in - * } catch (e: AuthException.InvalidCredentialsException) { - * // Wrong code entered - * showError("Invalid verification code") - * } catch (e: AuthException.SessionExpiredException) { - * // Code expired - * showError("Verification code expired. Please request a new one.") - * } - * ``` - * - * @param config The [AuthUIConfiguration] containing authentication settings - * @param verificationId The verification ID from [AuthState.PhoneNumberVerificationRequired] - * @param code The 6-digit verification code entered by the user - * - * @return [AuthResult] containing the signed-in user - * - * @throws AuthException.InvalidCredentialsException if the code is incorrect or expired - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs - */ -internal suspend fun FirebaseAuthUI.submitVerificationCode( - context: Context, - config: AuthUIConfiguration, - verificationId: String, - code: String, - credentialProvider: AuthProvider.Phone.CredentialProvider = AuthProvider.Phone.DefaultCredentialProvider(), -): AuthResult? { - try { - updateAuthState(AuthState.Loading("Submitting verification code...")) - val credential = credentialProvider.getCredential(verificationId, code) - return signInWithPhoneAuthCredential( - context = context, - config = config, - credential = credential - ) - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Submit verification code was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } -} - -/** - * Signs in a user with a phone authentication credential. - * - * This method is the final step in the phone authentication flow. It takes a - * [PhoneAuthCredential] (either from instant verification or manual code entry) and - * signs in the user. The method handles both normal sign-in and anonymous account - * upgrade scenarios by delegating to [signInAndLinkWithCredential]. - * - * **When to call this:** - * - After [verifyPhoneNumber] emits [AuthState.SMSAutoVerified] (instant verification) - * - Called internally by [submitVerificationCode] (manual verification) - * - * The method automatically handles: - * - Normal sign-in for new or returning users - * - Linking phone credential to anonymous accounts (if enabled in config) - * - Throwing [AuthException.AccountLinkingRequiredException] if phone number already exists on another account - * - * **Example: Sign in after instant verification** - * ```kotlin - * authUI.authStateFlow().collect { state -> - * when (state) { - * is AuthState.SMSAutoVerified -> { - * // Phone was instantly verified - * showToast("Phone verified automatically!") - * - * // Now sign in with the credential - * val result = firebaseAuthUI.signInWithPhoneAuthCredential( - * config = authUIConfig, - * credential = state.credential - * ) - * // User is now signed in - * } - * } - * } - * ``` - * - * **Example: Anonymous upgrade with collision** - * ```kotlin - * // User is currently anonymous - * try { - * firebaseAuthUI.signInWithPhoneAuthCredential( - * config = authUIConfig, - * credential = phoneCredential - * ) - * } catch (e: AuthException.AccountLinkingRequiredException) { - * // Phone number already exists on another account - * // Account linking required - show account linking screen - * // User needs to sign in with existing account to link - * } - * ``` - * - * @param config The [AuthUIConfiguration] containing authentication settings - * @param credential The [PhoneAuthCredential] to use for signing in - * - * @return [AuthResult] containing the signed-in user, or null if anonymous upgrade collision occurred - * - * @throws AuthException.InvalidCredentialsException if the credential is invalid or expired - * @throws AuthException.EmailAlreadyInUseException if phone number is linked to another account - * @throws AuthException.AuthCancelledException if the operation is cancelled - * @throws AuthException.NetworkException if a network error occurs - */ -internal suspend fun FirebaseAuthUI.signInWithPhoneAuthCredential( - context: Context, - config: AuthUIConfiguration, - credential: PhoneAuthCredential, -): AuthResult? { - try { - updateAuthState(AuthState.Loading("Signing in with phone...")) - val result = signInAndLinkWithCredential( - config = config, - credential = credential, - ) - - // Save sign-in preference for "Continue as..." feature - if (result != null) { - try { - val user = auth.currentUser - val identifier = user?.phoneNumber - if (identifier != null) { - SignInPreferenceManager.saveLastSignIn( - context = context, - providerId = "phone", - identifier = identifier - ) - android.util.Log.d("PhoneAuthProvider", "Sign-in preference saved for: $identifier") - } - } catch (e: Exception) { - // Failed to save preference - log but don't break auth flow - android.util.Log.w("PhoneAuthProvider", "Failed to save sign-in preference", e) - } - } - - return result - } catch (e: CancellationException) { - val cancelledException = AuthException.AuthCancelledException( - message = "Sign in with phone was cancelled", - cause = e - ) - updateAuthState(AuthState.Error(cancelledException)) - throw cancelledException - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - throw e - } catch (e: Exception) { - val authException = AuthException.from(e) - updateAuthState(AuthState.Error(authException)) - throw authException - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt index 3fec269be1..95baa37c11 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -32,90 +33,27 @@ val LocalAuthUIStringProvider = staticCompositionLocalOf { /** * An interface for providing localized string resources. This interface defines methods for all - * user-facing strings, such as initializing(), signInWithGoogle(), invalidEmailAddress(), - * passwordsDoNotMatch(), etc., allowing for complete localization of the UI. - * - * @sample AuthUIStringProviderSample + * user-facing strings allowing for localization of the UI. */ interface AuthUIStringProvider { /** Loading text displayed during initialization or processing states */ val initializing: String - /** Text for Google Provider */ - val googleProvider: String - - /** Text for Facebook Provider */ - val facebookProvider: String - - /** Text for Twitter Provider */ - val twitterProvider: String - - /** Text for Github Provider */ - val githubProvider: String - - /** Text for Phone Provider */ - val phoneProvider: String - /** Text for Email Provider */ val emailProvider: String /** Button text for Google sign-in option */ val signInWithGoogle: String - /** Button text for Facebook sign-in option */ - val signInWithFacebook: String - - /** Button text for Twitter sign-in option */ - val signInWithTwitter: String - - /** Button text for Github sign-in option */ - val signInWithGithub: String - /** Button text for Email sign-in option */ val signInWithEmail: String - /** Button text for Phone sign-in option */ - val signInWithPhone: String - - /** Button text for Anonymous sign-in option */ - val signInAnonymously: String - - /** Button text for Apple sign-in option */ - val signInWithApple: String - - /** Button text for Microsoft sign-in option */ - val signInWithMicrosoft: String - - /** Button text for Yahoo sign-in option */ - val signInWithYahoo: String - /** Button text for Google continue option */ val continueWithGoogle: String - /** Button text for Facebook continue option */ - val continueWithFacebook: String - - /** Button text for Twitter continue option */ - val continueWithTwitter: String - - /** Button text for Github continue option */ - val continueWithGithub: String - /** Button text for Email continue option */ val continueWithEmail: String - /** Button text for Phone continue option */ - val continueWithPhone: String - - /** Button text for Apple continue option */ - val continueWithApple: String - - /** Button text for Microsoft continue option */ - val continueWithMicrosoft: String - - /** Button text for Yahoo continue option */ - val continueWithYahoo: String - /** Error message when email address field is empty */ val missingEmailAddress: String @@ -228,31 +166,6 @@ interface AuthUIStringProvider { /** Message shown when email mismatch occurs */ val emailMismatchMessage: String - // Phone Authentication Strings - /** Phone number entry form title */ - val verifyPhoneNumberTitle: String - - /** Hint for phone input field */ - val phoneHint: String - - /** Hint for country input field */ - val countryHint: String - - /** Invalid phone number error */ - val invalidPhoneNumber: String - - /** Missing phone number error */ - val missingPhoneNumber: String - - /** Phone verification code entry form title */ - val enterConfirmationCode: String - - /** Button text to verify phone number */ - val verifyPhoneNumber: String - - /** Resend code countdown timer */ - val resendCodeIn: String - /** Resend code link text */ val resendCode: String @@ -268,36 +181,18 @@ interface AuthUIStringProvider { /** SMS terms of service warning */ val smsTermsOfService: String - /** Enter phone number title */ - val enterPhoneNumberTitle: String - - /** Phone number hint */ - val phoneNumberHint: String - - /** Send verification code button text */ - val sendVerificationCode: String - /** Enter verification code title with phone number */ fun enterVerificationCodeTitle(phoneNumber: String): String /** Verification code hint */ val verificationCodeHint: String - /** Change phone number link text */ - val changePhoneNumber: String - /** Missing verification code error */ val missingVerificationCode: String /** Invalid verification code error */ val invalidVerificationCode: String - /** Select country modal sheet title */ - val countrySelectorModalTitle: String - - /** Select country modal sheet input field hint */ - val searchCountriesHint: String - // Provider Picker Strings /** Common button text for sign in */ val signInDefault: String diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt deleted file mode 100644 index 887b3bb790..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProviderSample.kt +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.configuration.string_provider - -import android.content.Context -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider - -class AuthUIStringProviderSample { - /** - * Override specific strings while delegating others to default provider - */ - class CustomAuthUIStringProvider( - private val defaultProvider: AuthUIStringProvider - ) : AuthUIStringProvider by defaultProvider { - - // Override only the strings you want to customize - override val signInWithGoogle: String = "Continue with Google • MyApp" - override val signInWithFacebook: String = "Continue with Facebook • MyApp" - - // Add custom branding to common actions - override val continueText: String = "Continue to MyApp" - override val signInDefault: String = "Sign in to MyApp" - - // Custom MFA messaging - override val enterTOTPCode: String = - "Enter the 6-digit code from your authenticator app to secure your MyApp account" - } - - fun createCustomConfiguration(applicationContext: Context): AuthUIConfiguration { - val customStringProvider = - CustomAuthUIStringProvider(DefaultAuthUIStringProvider(applicationContext)) - return authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Google( - scopes = listOf(), - serverClientId = "" - ) - ) - } - stringProvider = customStringProvider - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 7e55365358..6a66dfa19b 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -35,16 +36,6 @@ class DefaultAuthUIStringProvider( /** * Auth Provider strings */ - override val googleProvider: String - get() = localizedContext.getString(R.string.fui_idp_name_google) - override val facebookProvider: String - get() = localizedContext.getString(R.string.fui_idp_name_facebook) - override val twitterProvider: String - get() = localizedContext.getString(R.string.fui_idp_name_twitter) - override val githubProvider: String - get() = localizedContext.getString(R.string.fui_idp_name_github) - override val phoneProvider: String - get() = localizedContext.getString(R.string.fui_idp_name_phone) override val emailProvider: String get() = localizedContext.getString(R.string.fui_idp_name_email) @@ -53,46 +44,16 @@ class DefaultAuthUIStringProvider( */ override val signInWithGoogle: String get() = localizedContext.getString(R.string.fui_sign_in_with_google) - override val signInWithFacebook: String - get() = localizedContext.getString(R.string.fui_sign_in_with_facebook) - override val signInWithTwitter: String - get() = localizedContext.getString(R.string.fui_sign_in_with_twitter) - override val signInWithGithub: String - get() = localizedContext.getString(R.string.fui_sign_in_with_github) override val signInWithEmail: String get() = localizedContext.getString(R.string.fui_sign_in_with_email) - override val signInWithPhone: String - get() = localizedContext.getString(R.string.fui_sign_in_with_phone) - override val signInAnonymously: String - get() = localizedContext.getString(R.string.fui_sign_in_anonymously) - override val signInWithApple: String - get() = localizedContext.getString(R.string.fui_sign_in_with_apple) - override val signInWithMicrosoft: String - get() = localizedContext.getString(R.string.fui_sign_in_with_microsoft) - override val signInWithYahoo: String - get() = localizedContext.getString(R.string.fui_sign_in_with_yahoo) /** * Auth Provider "Continue With" Button Strings */ override val continueWithGoogle: String get() = localizedContext.getString(R.string.fui_continue_with_google) - override val continueWithFacebook: String - get() = localizedContext.getString(R.string.fui_continue_with_facebook) - override val continueWithTwitter: String - get() = localizedContext.getString(R.string.fui_continue_with_twitter) - override val continueWithGithub: String - get() = localizedContext.getString(R.string.fui_continue_with_github) override val continueWithEmail: String get() = localizedContext.getString(R.string.fui_continue_with_email) - override val continueWithPhone: String - get() = localizedContext.getString(R.string.fui_continue_with_phone) - override val continueWithApple: String - get() = localizedContext.getString(R.string.fui_continue_with_apple) - override val continueWithMicrosoft: String - get() = localizedContext.getString(R.string.fui_continue_with_microsoft) - override val continueWithYahoo: String - get() = localizedContext.getString(R.string.fui_continue_with_yahoo) /** * Email Validator Strings @@ -204,25 +165,6 @@ class DefaultAuthUIStringProvider( override val emailMismatchMessage: String get() = localizedContext.getString(R.string.fui_error_unknown) - /** - * Phone Authentication Strings - */ - override val verifyPhoneNumberTitle: String - get() = localizedContext.getString(R.string.fui_verify_phone_number_title) - override val phoneHint: String - get() = localizedContext.getString(R.string.fui_phone_hint) - override val countryHint: String - get() = localizedContext.getString(R.string.fui_country_hint) - override val invalidPhoneNumber: String - get() = localizedContext.getString(R.string.fui_invalid_phone_number) - override val missingPhoneNumber: String - get() = localizedContext.getString(R.string.fui_required_field) - override val enterConfirmationCode: String - get() = localizedContext.getString(R.string.fui_enter_confirmation_code) - override val verifyPhoneNumber: String - get() = localizedContext.getString(R.string.fui_verify_phone_number) - override val resendCodeIn: String - get() = localizedContext.getString(R.string.fui_resend_code_in) override val resendCode: String get() = localizedContext.getString(R.string.fui_resend_code) @@ -236,36 +178,18 @@ class DefaultAuthUIStringProvider( override val smsTermsOfService: String get() = localizedContext.getString(R.string.fui_sms_terms_of_service) - override val enterPhoneNumberTitle: String - get() = localizedContext.getString(R.string.fui_verify_phone_number_title) - - override val phoneNumberHint: String - get() = localizedContext.getString(R.string.fui_phone_hint) - - override val sendVerificationCode: String - get() = localizedContext.getString(R.string.fui_next_default) - override fun enterVerificationCodeTitle(phoneNumber: String): String = localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber override val verificationCodeHint: String get() = localizedContext.getString(R.string.fui_enter_confirmation_code) - override val changePhoneNumber: String - get() = localizedContext.getString(R.string.fui_change_phone_number) - override val missingVerificationCode: String get() = localizedContext.getString(R.string.fui_required_field) override val invalidVerificationCode: String get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) - override val countrySelectorModalTitle: String - get() = localizedContext.getString(R.string.fui_country_selector_title) - - override val searchCountriesHint: String - get() = localizedContext.getString(R.string.fui_search_country_field_hint) - /** * Multi-Factor Authentication Strings */ diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt index f7f54deb36..0d8e9aeb95 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/theme/ProviderStyleDefaults.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -21,7 +22,7 @@ import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Pro * customized by passing a modified map to [AuthUITheme.fromMaterialTheme]. * * Individual provider styles can be accessed and customized using the public properties - * (e.g., [Google], [Facebook]) and then modified using the [AuthUITheme.ProviderStyle.copy] method. + * (e.g., [Google]) and then modified using the [AuthUITheme.ProviderStyle.copy] method. */ object ProviderStyleDefaults { val Google = AuthUITheme.ProviderStyle( @@ -30,71 +31,15 @@ object ProviderStyleDefaults { contentColor = Color(0xFF757575) ) - val Facebook = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp), - backgroundColor = Color(0xFF1877F2), - contentColor = Color.White - ) - - val Twitter = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_twitter_x_white_24dp), - backgroundColor = Color.Black, - contentColor = Color.White - ) - - val Github = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_github_white_24dp), - backgroundColor = Color(0xFF24292E), - contentColor = Color.White - ) - val Email = AuthUITheme.ProviderStyle( icon = AuthUIAsset.Resource(R.drawable.fui_ic_mail_white_24dp), backgroundColor = Color(0xFFD0021B), contentColor = Color.White ) - val Phone = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_phone_white_24dp), - backgroundColor = Color(0xFF43C5A5), - contentColor = Color.White - ) - - val Anonymous = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_anonymous_white_24dp), - backgroundColor = Color(0xFFF4B400), - contentColor = Color.White - ) - - val Microsoft = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_microsoft_24dp), - backgroundColor = Color(0xFF2F2F2F), - contentColor = Color.White - ) - - val Yahoo = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_yahoo_24dp), - backgroundColor = Color(0xFF720E9E), - contentColor = Color.White - ) - - val Apple = AuthUITheme.ProviderStyle( - icon = AuthUIAsset.Resource(R.drawable.fui_ic_apple_white_24dp), - backgroundColor = Color.Black, - contentColor = Color.White - ) - val default: Map get() = mapOf( Provider.GOOGLE.id to Google, - Provider.FACEBOOK.id to Facebook, - Provider.TWITTER.id to Twitter, - Provider.GITHUB.id to Github, Provider.EMAIL.id to Email, - Provider.PHONE.id to Phone, - Provider.ANONYMOUS.id to Anonymous, - Provider.MICROSOFT.id to Microsoft, - Provider.YAHOO.id to Yahoo, - Provider.APPLE.id to Apple ) } \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt deleted file mode 100644 index 2a944a237d..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/validators/PhoneNumberValidator.kt +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.configuration.validators - -import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import com.google.i18n.phonenumbers.NumberParseException -import com.google.i18n.phonenumbers.PhoneNumberUtil - -internal class PhoneNumberValidator( - override val stringProvider: AuthUIStringProvider, - val selectedCountry: CountryData, -) : - FieldValidator { - private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) - private val phoneNumberUtil = PhoneNumberUtil.getInstance() - - override val hasError: Boolean - get() = _validationStatus.hasError - - override val errorMessage: String - get() = _validationStatus.errorMessage ?: "" - - override fun validate(value: String): Boolean { - if (value.isEmpty()) { - _validationStatus = FieldValidationStatus( - hasError = true, - errorMessage = stringProvider.missingPhoneNumber - ) - return false - } - - try { - val phoneNumber = phoneNumberUtil.parse(value, selectedCountry.countryCode) - val isValid = phoneNumberUtil.isValidNumber(phoneNumber) - - if (!isValid) { - _validationStatus = FieldValidationStatus( - hasError = true, - errorMessage = stringProvider.invalidPhoneNumber - ) - return false - } - } catch (_: NumberParseException) { - _validationStatus = FieldValidationStatus( - hasError = true, - errorMessage = stringProvider.invalidPhoneNumber - ) - return false - } - - _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null) - return true - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt index 43d06a8d02..2100b8f3a1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/AuthProviderButton.kt @@ -1,12 +1,12 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android package com.battlelancer.seriesguide.backend.auth.ui.components -import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,8 +17,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -27,7 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow @@ -37,7 +34,6 @@ import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Aut import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme import com.battlelancer.seriesguide.backend.auth.configuration.theme.LocalAuthUITheme import com.battlelancer.seriesguide.backend.auth.configuration.theme.ProviderStyleDefaults @@ -81,10 +77,9 @@ fun AuthProviderButton( label: String? = null, showAsContinue: Boolean = false, ) { - val context = LocalContext.current val authTheme = LocalAuthUITheme.current val providerLabel = - label ?: resolveProviderLabel(provider, stringProvider, context, showAsContinue) + label ?: resolveProviderLabel(provider, stringProvider, showAsContinue) val providerStyle = resolveProviderStyle( provider = provider, style = style, @@ -205,32 +200,12 @@ internal fun resolveProviderStyle( internal fun resolveProviderLabel( provider: AuthProvider, stringProvider: AuthUIStringProvider, - context: Context, showAsContinue: Boolean = false, ): String = when (provider) { is AuthProvider.GenericOAuth -> provider.buttonLabel - is AuthProvider.Apple -> { - // Use Apple-specific locale if provided, otherwise use default stringProvider - if (provider.locale != null) { - val appleLocale = java.util.Locale.forLanguageTag(provider.locale) - val appleStringProvider = DefaultAuthUIStringProvider(context, appleLocale) - if (showAsContinue) appleStringProvider.continueWithApple else appleStringProvider.signInWithApple - } else { - if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple - } - } - else -> when (Provider.fromId(provider.providerId)) { Provider.GOOGLE -> if (showAsContinue) stringProvider.continueWithGoogle else stringProvider.signInWithGoogle - Provider.FACEBOOK -> if (showAsContinue) stringProvider.continueWithFacebook else stringProvider.signInWithFacebook - Provider.TWITTER -> if (showAsContinue) stringProvider.continueWithTwitter else stringProvider.signInWithTwitter - Provider.GITHUB -> if (showAsContinue) stringProvider.continueWithGithub else stringProvider.signInWithGithub Provider.EMAIL -> if (showAsContinue) stringProvider.continueWithEmail else stringProvider.signInWithEmail - Provider.PHONE -> if (showAsContinue) stringProvider.continueWithPhone else stringProvider.signInWithPhone - Provider.ANONYMOUS -> stringProvider.signInAnonymously - Provider.MICROSOFT -> if (showAsContinue) stringProvider.continueWithMicrosoft else stringProvider.signInWithMicrosoft - Provider.YAHOO -> if (showAsContinue) stringProvider.continueWithYahoo else stringProvider.signInWithYahoo - Provider.APPLE -> if (showAsContinue) stringProvider.continueWithApple else stringProvider.signInWithApple null -> "Unknown Provider" } } @@ -253,15 +228,6 @@ private fun PreviewAuthProviderButton() { onClick = {}, stringProvider = DefaultAuthUIStringProvider(context) ) - AuthProviderButton( - provider = AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null, - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) AuthProviderButton( provider = AuthProvider.Google( scopes = emptyList(), @@ -270,89 +236,20 @@ private fun PreviewAuthProviderButton() { onClick = {}, stringProvider = DefaultAuthUIStringProvider(context) ) - AuthProviderButton( - provider = AuthProvider.Facebook(), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Twitter( - customParameters = emptyMap() - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Github( - customParameters = emptyMap() - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Microsoft( - tenant = null, - customParameters = emptyMap() - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Yahoo( - customParameters = emptyMap() - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Apple( - locale = null, - customParameters = emptyMap() - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.Anonymous, - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.GenericOAuth( - providerName = "Generic Provider", - providerId = "google.com", - scopes = emptyList(), - customParameters = emptyMap(), - buttonLabel = "Generic Provider", - buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), - buttonColor = Color.Gray, - contentColor = Color.White - ), - onClick = {}, - stringProvider = DefaultAuthUIStringProvider(context) - ) - AuthProviderButton( - provider = AuthProvider.GenericOAuth( - providerName = "Generic Provider", - providerId = "google.com", - scopes = emptyList(), - customParameters = emptyMap(), - buttonLabel = "Custom Style", - buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), - buttonColor = Color.Gray, - contentColor = Color.White - ), - onClick = {}, - style = AuthUITheme.ProviderStyle( - icon = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]?.icon, - backgroundColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.backgroundColor, - contentColor = AuthUITheme.Default.providerStyles[Provider.MICROSOFT.id]!!.contentColor, - iconTint = Color.Red, - shape = RoundedCornerShape(24.dp), - elevation = 6.dp - ), - stringProvider = DefaultAuthUIStringProvider(context) - ) +// AuthProviderButton( +// provider = AuthProvider.GenericOAuth( +// providerName = "Generic Provider", +// providerId = "google.com", +// scopes = emptyList(), +// customParameters = emptyMap(), +// buttonLabel = "Generic Provider", +// buttonIcon = AuthUIAsset.Vector(Icons.Default.Star), +// buttonColor = Color.Gray, +// contentColor = Color.White +// ), +// onClick = {}, +// stringProvider = DefaultAuthUIStringProvider(context) +// ) AuthProviderButton( provider = AuthProvider.GenericOAuth( providerName = "Generic Provider", diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt deleted file mode 100644 index 403602c045..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/CountrySelector.kt +++ /dev/null @@ -1,210 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.ui.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.data.ALL_COUNTRIES -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import com.battlelancer.seriesguide.backend.auth.util.CountryUtils -import kotlinx.coroutines.launch - -/** - * A country selector component that displays the selected country's flag and dial code with a dropdown icon. - * Designed to be used as a leadingIcon in a TextField. - * - * @param selectedCountry The currently selected country. - * @param onCountrySelected Callback when a country is selected. - * @param enabled Whether the selector is enabled. - * @param allowedCountries Optional set of allowed country codes to filter the list. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CountrySelector( - selectedCountry: CountryData, - onCountrySelected: (CountryData) -> Unit, - enabled: Boolean = true, - allowedCountries: Set? = null, -) { - val context = LocalContext.current - val stringProvider = LocalAuthUIStringProvider.current - val sheetState = rememberModalBottomSheetState() - val scope = rememberCoroutineScope() - var showBottomSheet by remember { mutableStateOf(false) } - var searchQuery by remember { mutableStateOf("") } - - val countriesList = remember(allowedCountries) { - if (allowedCountries != null) { - CountryUtils.filterByAllowedCountries(allowedCountries) - } else { - ALL_COUNTRIES - } - } - - val filteredCountries = remember(searchQuery, countriesList) { - if (searchQuery.isEmpty()) { - countriesList - } else { - CountryUtils.search(searchQuery).filter { country -> - countriesList.any { it.countryCode == country.countryCode } - } - } - } - - // Clickable row showing flag, dial code and dropdown icon - Row( - modifier = Modifier - .fillMaxHeight() - .clickable(enabled = enabled) { - showBottomSheet = true - } - .padding(start = 8.dp) - .semantics { - role = Role.DropdownList - contentDescription = "Country selector" - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = selectedCountry.flagEmoji, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = selectedCountry.dialCode, - style = MaterialTheme.typography.bodyLarge, - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "Select country", - modifier = Modifier.padding(PaddingValues.Zero) - ) - } - - if (showBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - showBottomSheet = false - searchQuery = "" - }, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - ) { - Text( - text = stringProvider.countrySelectorModalTitle, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp) - ) - - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - label = { Text(stringProvider.searchCountriesHint) }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(16.dp)) - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .height(500.dp) - .testTag("CountrySelector LazyColumn") - ) { - items(filteredCountries) { country -> - Button( - onClick = { - onCountrySelected(country) - scope.launch { - sheetState.hide() - showBottomSheet = false - searchQuery = "" - } - }, - colors = ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - containerColor = Color.Transparent - ), - contentPadding = PaddingValues.Zero - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = country.flagEmoji, - style = MaterialTheme.typography.headlineMedium - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = country.name, - style = MaterialTheme.typography.bodyLarge - ) - } - Text( - text = country.dialCode, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt index c8ec576dfd..4e9016fdad 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/method_picker/AuthMethodPicker.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -32,7 +33,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.Provider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUIAsset import com.battlelancer.seriesguide.backend.auth.ui.components.AuthProviderButton @@ -212,34 +212,10 @@ fun PreviewAuthMethodPicker() { emailLinkActionCodeSettings = null, passwordValidationRules = emptyList() ), - AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null, - ), AuthProvider.Google( scopes = emptyList(), serverClientId = null - ), - AuthProvider.Facebook(), - AuthProvider.Twitter( - customParameters = emptyMap() - ), - AuthProvider.Github( - customParameters = emptyMap() - ), - AuthProvider.Microsoft( - tenant = null, - customParameters = emptyMap() - ), - AuthProvider.Yahoo( - customParameters = emptyMap() - ), - AuthProvider.Apple( - locale = null, - customParameters = emptyMap() - ), - AuthProvider.Anonymous, + ) ), logo = AuthUIAsset.Resource(R.drawable.fui_ic_check_circle_black_128dp), onProviderSelected = { provider -> diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 93619808eb..89c1f4ad06 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -55,10 +55,8 @@ import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberAnonymousSignInHandler import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberGoogleSignInHandler import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberOAuthSignInHandler -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.rememberSignInWithFacebookLauncher import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithEmailLink import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.DefaultAuthUIStringProvider @@ -70,7 +68,6 @@ import com.battlelancer.seriesguide.backend.auth.ui.method_picker.AuthMethodPick import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaChallengeScreen import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaEnrollmentScreen import com.battlelancer.seriesguide.backend.auth.ui.screens.email.EmailAuthScreen -import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.PhoneAuthScreen import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager import com.google.firebase.auth.AuthCredential @@ -126,30 +123,14 @@ fun FirebaseAuthScreen( lastSignInPreference.value = SignInPreferenceManager.getLastSignIn(context) } - val anonymousProvider = - configuration.providers.filterIsInstance().firstOrNull() val googleProvider = configuration.providers.filterIsInstance().firstOrNull() val emailProvider = configuration.providers.filterIsInstance().firstOrNull() - val facebookProvider = - configuration.providers.filterIsInstance().firstOrNull() - val appleProvider = configuration.providers.filterIsInstance().firstOrNull() - val githubProvider = - configuration.providers.filterIsInstance().firstOrNull() - val microsoftProvider = - configuration.providers.filterIsInstance().firstOrNull() - val yahooProvider = configuration.providers.filterIsInstance().firstOrNull() - val twitterProvider = - configuration.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = configuration.providers.filterIsInstance() val logoAsset = configuration.logo - val onSignInAnonymously = anonymousProvider?.let { - authUI.rememberAnonymousSignInHandler() - } - val onSignInWithGoogle = googleProvider?.let { authUI.rememberGoogleSignInHandler( context = context, @@ -158,59 +139,6 @@ fun FirebaseAuthScreen( ) } - val onSignInWithFacebook = facebookProvider?.let { - authUI.rememberSignInWithFacebookLauncher( - context = context, - config = configuration, - provider = it - ) - } - - val onSignInWithApple = appleProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithGithub = githubProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithMicrosoft = microsoftProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithYahoo = yahooProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - - val onSignInWithTwitter = twitterProvider?.let { - authUI.rememberOAuthSignInHandler( - context = context, - activity = activity, - config = configuration, - provider = it - ) - } - val genericOAuthHandlers = genericOAuthProviders.associateWith { authUI.rememberOAuthSignInHandler( context = context, @@ -257,30 +185,12 @@ fun FirebaseAuthScreen( lastSignInPreference = lastSignInPreference.value, onProviderSelected = { provider -> when (provider) { - is AuthProvider.Anonymous -> onSignInAnonymously?.invoke() - is AuthProvider.Email -> { navController.navigate(AuthRoute.Email.route) } - is AuthProvider.Phone -> { - navController.navigate(AuthRoute.Phone.route) - } - is AuthProvider.Google -> onSignInWithGoogle?.invoke() - is AuthProvider.Facebook -> onSignInWithFacebook?.invoke() - - is AuthProvider.Apple -> onSignInWithApple?.invoke() - - is AuthProvider.Github -> onSignInWithGithub?.invoke() - - is AuthProvider.Microsoft -> onSignInWithMicrosoft?.invoke() - - is AuthProvider.Yahoo -> onSignInWithYahoo?.invoke() - - is AuthProvider.Twitter -> onSignInWithTwitter?.invoke() - is AuthProvider.GenericOAuth -> genericOAuthHandlers[provider]?.invoke() else -> { @@ -324,26 +234,6 @@ fun FirebaseAuthScreen( ) } - composable(AuthRoute.Phone.route) { - PhoneAuthScreen( - context = context, - configuration = configuration, - authUI = authUI, - onSuccess = {}, - onError = { exception -> - onSignInFailure(exception) - }, - onCancel = { - if (!navController.popBackStack()) { - navController.navigate(AuthRoute.MethodPicker.route) { - popUpTo(AuthRoute.MethodPicker.route) { inclusive = true } - launchSingleTop = true - } - } - } - ) - } - composable(AuthRoute.Success.route) { val uiContext = remember(authState, stringProvider) { AuthSuccessUiContext( @@ -656,7 +546,6 @@ fun FirebaseAuthScreen( sealed class AuthRoute(val route: String) { object MethodPicker : AuthRoute("auth_method_picker") object Email : AuthRoute("auth_email") - object Phone : AuthRoute("auth_phone") object Success : AuthRoute("auth_success") object MfaEnrollment : AuthRoute("auth_mfa_enrollment") object MfaChallenge : AuthRoute("auth_mfa_challenge") diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt deleted file mode 100644 index dce292c020..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterPhoneNumberUI.kt +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.ui.screens.phone - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme -import com.battlelancer.seriesguide.backend.auth.configuration.validators.PhoneNumberValidator -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import com.battlelancer.seriesguide.backend.auth.ui.components.AuthTextField -import com.battlelancer.seriesguide.backend.auth.ui.components.CountrySelector -import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm -import com.battlelancer.seriesguide.backend.auth.util.CountryUtils - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EnterPhoneNumberUI( - modifier: Modifier = Modifier, - configuration: AuthUIConfiguration, - isLoading: Boolean, - phoneNumber: String, - selectedCountry: CountryData, - onPhoneNumberChange: (String) -> Unit, - onCountrySelected: (CountryData) -> Unit, - onSendCodeClick: () -> Unit, - title: String? = null, - onNavigateBack: (() -> Unit)? = null, -) { - val context = LocalContext.current - val provider = configuration.providers.filterIsInstance().first() - val stringProvider = LocalAuthUIStringProvider.current - val phoneNumberValidator = remember(selectedCountry) { - PhoneNumberValidator(stringProvider, selectedCountry) - } - - val isFormValid = remember(selectedCountry, phoneNumber) { - derivedStateOf { - phoneNumberValidator.validate(phoneNumber) - } - } - - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Text(title ?: stringProvider.signInWithPhone) - }, - navigationIcon = { - if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringProvider.backAction - ) - } - } - }, - colors = AuthUITheme.topAppBarColors - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - ) { - Text(stringProvider.enterPhoneNumberTitle) - Spacer(modifier = Modifier.height(16.dp)) - AuthTextField( - value = phoneNumber, - validator = phoneNumberValidator, - enabled = !isLoading, - label = { - Text(stringProvider.phoneNumberHint) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Phone - ), - leadingIcon = { - CountrySelector( - selectedCountry = selectedCountry, - onCountrySelected = onCountrySelected, - enabled = !isLoading, - allowedCountries = provider.allowedCountries?.toSet() - ) - }, - onValueChange = { - onPhoneNumberChange(it) - } - ) - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier - .align(Alignment.End), - ) { - Button( - onClick = onSendCodeClick, - enabled = !isLoading && isFormValid.value, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier - .size(16.dp) - ) - } else { - Text(stringProvider.sendVerificationCode.uppercase()) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - TermsAndPrivacyForm( - modifier = Modifier.align(Alignment.End), - tosUrl = configuration.tosUrl, - ppUrl = configuration.privacyPolicyUrl, - ) - } - } -} - -@Preview -@Composable -fun PreviewEnterPhoneNumberUI() { - val applicationContext = LocalContext.current - val provider = AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null, - timeout = 60L, - isInstantVerificationEnabled = true - ) - - AuthUITheme { - EnterPhoneNumberUI( - configuration = authUIConfiguration { - context = applicationContext - providers { provider(provider) } - tosUrl = "" - privacyPolicyUrl = "" - }, - isLoading = false, - phoneNumber = "", - selectedCountry = CountryUtils.getDefaultCountry(), - onPhoneNumberChange = {}, - onCountrySelected = {}, - onSendCodeClick = {}, - ) - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt deleted file mode 100644 index 553e358bce..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/EnterVerificationCodeUI.kt +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.ui.screens.phone - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme -import com.battlelancer.seriesguide.backend.auth.configuration.validators.VerificationCodeValidator -import com.battlelancer.seriesguide.backend.auth.ui.components.TermsAndPrivacyForm -import com.battlelancer.seriesguide.backend.auth.ui.components.VerificationCodeInputField -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EnterVerificationCodeUI( - modifier: Modifier = Modifier, - configuration: AuthUIConfiguration, - isLoading: Boolean, - verificationCode: String, - fullPhoneNumber: String, - resendTimer: Int, - onVerificationCodeChange: (String) -> Unit, - onVerifyCodeClick: () -> Unit, - onResendCodeClick: () -> Unit, - onChangeNumberClick: () -> Unit, - title: String? = null, - onNavigateBack: (() -> Unit)? = null, -) { - val context = LocalContext.current - val stringProvider = LocalAuthUIStringProvider.current - val verificationCodeValidator = remember { - VerificationCodeValidator(stringProvider) - } - - val isFormValid = remember(verificationCode) { - derivedStateOf { - verificationCodeValidator.validate(verificationCode) - } - } - - val resendEnabled = resendTimer == 0 && !isLoading - - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Text(title ?: stringProvider.verifyPhoneNumber) - }, - navigationIcon = { - if (onNavigateBack != null) { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringProvider.backAction - ) - } - } - }, - colors = AuthUITheme.topAppBarColors - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .safeDrawingPadding() - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = stringProvider.enterVerificationCodeTitle(fullPhoneNumber), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = Modifier.height(8.dp)) - - TextButton( - modifier = Modifier.align(Alignment.Start), - onClick = onChangeNumberClick, - enabled = !isLoading, - contentPadding = PaddingValues.Zero - ) { - Text( - text = stringProvider.changePhoneNumber, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - textDecoration = TextDecoration.Underline - ) - } - Spacer(modifier = Modifier.height(16.dp)) - - VerificationCodeInputField( - modifier = Modifier.align(Alignment.CenterHorizontally), - validator = verificationCodeValidator, - onCodeChange = onVerificationCodeChange - ) - Spacer(modifier = Modifier.height(8.dp)) - - TextButton( - modifier = Modifier.align(Alignment.Start), - onClick = onResendCodeClick, - enabled = resendEnabled, - contentPadding = PaddingValues.Zero - ) { - Text( - text = if (resendTimer > 0) { - val minutes = resendTimer / 60 - val seconds = resendTimer % 60 - val timeFormatted = - "$minutes:${String.format(Locale.ROOT, "%02d", seconds)}" - stringProvider.resendCodeTimer(timeFormatted) - } else { - stringProvider.resendCode - }, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - textDecoration = if (resendEnabled) TextDecoration.Underline else TextDecoration.None - ) - } - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier - .align(Alignment.End), - ) { - Button( - onClick = onVerifyCodeClick, - enabled = !isLoading && isFormValid.value, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier - .size(16.dp) - ) - } else { - Text(stringProvider.verifyPhoneNumber.uppercase()) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - TermsAndPrivacyForm( - modifier = Modifier.align(Alignment.End), - tosUrl = configuration.tosUrl, - ppUrl = configuration.privacyPolicyUrl, - ) - } - } -} - -@Preview -@Composable -fun PreviewEnterVerificationCodeUI() { - val applicationContext = LocalContext.current - val provider = AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null, - timeout = 60L, - isInstantVerificationEnabled = true - ) - - AuthUITheme { - EnterVerificationCodeUI( - configuration = authUIConfiguration { - context = applicationContext - providers { provider(provider) } - tosUrl = "" - privacyPolicyUrl = "" - }, - isLoading = false, - verificationCode = "", - fullPhoneNumber = "+1234567890", - resendTimer = 30, - onVerificationCodeChange = {}, - onVerifyCodeClick = {}, - onResendCodeClick = {}, - onChangeNumberClick = {}, - ) - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt deleted file mode 100644 index a4f4e1ef57..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/phone/PhoneAuthScreen.kt +++ /dev/null @@ -1,379 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.ui.screens.phone - -import android.content.Context -import android.util.Log -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import com.battlelancer.seriesguide.backend.auth.AuthException -import com.battlelancer.seriesguide.backend.auth.AuthState -import com.battlelancer.seriesguide.backend.auth.FirebaseAuthUI -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.signInWithPhoneAuthCredential -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.submitVerificationCode -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.verifyPhoneNumber -import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController -import com.battlelancer.seriesguide.backend.auth.util.CountryUtils -import com.google.firebase.auth.AuthResult -import com.google.firebase.auth.PhoneAuthProvider -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -enum class PhoneAuthStep { - /** - * An enum representing a view requiring a phone number which needs to be entered. - */ - EnterPhoneNumber, - - /** - * An enum representing a view requiring a phone number verification code which needs to - * be entered. - */ - EnterVerificationCode -} - -/** - * A class passed to the content slot, containing all the necessary information to render a custom - * UI for every step of the phone authentication process. - * - * @param step An enum representing the current step in the flow. Use a when expression on this - * to render the correct UI. - * @param isLoading true when an asynchronous operation (like sending or verifying a code) is in - * progress. - * @param error An optional error message to display to the user. - * @param phoneNumber (Step: [PhoneAuthStep.EnterPhoneNumber]) The current value of the phone - * number input field. - * @param onPhoneNumberChange (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked - * when the phone number input changes. - * @param selectedCountry (Step: [PhoneAuthStep.EnterPhoneNumber]) The currently selected country - * object, containing its name, dial code, and flag. - * @param onCountrySelected (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked when - * the user selects a new country. - * @param onSendCodeClick (Step: [PhoneAuthStep.EnterPhoneNumber]) A callback to be invoked to - * send the verification code to the entered number. - * @param verificationCode (Step: [PhoneAuthStep.EnterVerificationCode]) The current value of the - * 6-digit code input field. - * @param onVerificationCodeChange (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be - * invoked when the verification code input changes. - * @param onVerifyCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked - * to submit the verification code. - * @param fullPhoneNumber (Step: [PhoneAuthStep.EnterVerificationCode]) The formatted full phone - * number to display for user confirmation. - * @param onResendCodeClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to be invoked - * when the user clicks "Resend Code". - * @param resendTimer (Step: [PhoneAuthStep.EnterVerificationCode]) The number of seconds remaining - * before the "Resend" action is available. - * @param onChangeNumberClick (Step: [PhoneAuthStep.EnterVerificationCode]) A callback to navigate - * back to the [PhoneAuthStep.EnterPhoneNumber] step. - */ -class PhoneAuthContentState( - val step: PhoneAuthStep, - val isLoading: Boolean = false, - val error: String? = null, - val phoneNumber: String, - val onPhoneNumberChange: (String) -> Unit, - val selectedCountry: CountryData, - val onCountrySelected: (CountryData) -> Unit, - val onSendCodeClick: () -> Unit, - val verificationCode: String, - val onVerificationCodeChange: (String) -> Unit, - val onVerifyCodeClick: () -> Unit, - val fullPhoneNumber: String, - val onResendCodeClick: () -> Unit, - val resendTimer: Int = 0, - val onChangeNumberClick: () -> Unit, -) - -/** - * A stateful composable that manages the complete logic for phone number authentication. It handles - * the multi-step flow of sending and verifying an SMS code, exposing the state for each step to a - * custom UI via a trailing lambda (slot). This component renders no UI itself. - * - * @param context The Android context. - * @param configuration The authentication UI configuration containing the phone provider settings. - * @param authUI The FirebaseAuthUI instance used for authentication operations. - * @param onSuccess Callback invoked when authentication succeeds with the [AuthResult]. - * @param onError Callback invoked when an authentication error occurs. - * @param onCancel Callback invoked when the user cancels the authentication flow. - * @param modifier Optional [Modifier] for the composable. - * @param content A composable lambda that receives [PhoneAuthContentState] to render the UI for - * each step. If null, no UI will be rendered. - */ -@Composable -fun PhoneAuthScreen( - context: Context, - configuration: AuthUIConfiguration, - authUI: FirebaseAuthUI, - onSuccess: (AuthResult) -> Unit, - onError: (AuthException) -> Unit, - onCancel: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ((PhoneAuthContentState) -> Unit)? = null, -) { - val activity = LocalActivity.current - val provider = configuration.providers.filterIsInstance().first() - val stringProvider = LocalAuthUIStringProvider.current - val dialogController = LocalTopLevelDialogController.current - val coroutineScope = rememberCoroutineScope() - - val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } - val phoneNumberValue = rememberSaveable { mutableStateOf(provider.defaultNumber ?: "") } - val verificationCodeValue = rememberSaveable { mutableStateOf("") } - val selectedCountry = remember { - mutableStateOf( - provider.defaultCountryCode?.let { code -> - CountryUtils.findByCountryCode(code) - } ?: CountryUtils.getDefaultCountry() - ) - } - val fullPhoneNumber = remember(selectedCountry.value, phoneNumberValue.value) { - CountryUtils.formatPhoneNumber(selectedCountry.value.dialCode, phoneNumberValue.value) - } - val verificationId = rememberSaveable { mutableStateOf(null) } - val forceResendingToken = - rememberSaveable { mutableStateOf(null) } - val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } - val pendingVerificationPhoneNumber = remember { mutableStateOf(null) } - val verificationStartTime = remember { mutableStateOf(null) } - - val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) - val isLoading = authState is AuthState.Loading - val errorMessage = - if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null - - // Handle resend timer countdown - LaunchedEffect(resendTimerSeconds.intValue) { - if (resendTimerSeconds.intValue > 0) { - delay(1000) - resendTimerSeconds.intValue-- - } - } - - LaunchedEffect(authState) { - Log.d("PhoneAuthScreen", "Current state: $authState") - when (val state = authState) { - is AuthState.Success -> { - state.result?.let { result -> - onSuccess(result) - } - } - - is AuthState.PhoneNumberVerificationRequired -> { - verificationId.value = state.verificationId - forceResendingToken.value = state.forceResendingToken - step.value = PhoneAuthStep.EnterVerificationCode - resendTimerSeconds.intValue = provider.timeout.toInt() // Start 60-second countdown - } - - is AuthState.SMSAutoVerified -> { - // Auto-verification succeeded, sign in with the credential - // and clear pending verification tracking - pendingVerificationPhoneNumber.value = null - verificationStartTime.value = null - - coroutineScope.launch { - try { - authUI.signInWithPhoneAuthCredential( - context = context, - config = configuration, - credential = state.credential - ) - } catch (e: Exception) { - // Error will be handled by authState flow - } - } - } - - is AuthState.Error -> { - val exception = AuthException.from(state.exception) - onError(exception) - - // Show dialog for phone-specific errors using top-level controller - dialogController?.showErrorDialog( - exception = exception, - onRetry = { ex -> - when (ex) { - is AuthException.InvalidCredentialsException -> { - // User can retry with corrected code or phone number - } - else -> Unit - } - }, - onDismiss = { - // Dialog dismissed - } - ) - } - - is AuthState.Cancelled -> { - onCancel() - } - - else -> Unit - } - } - - val state = PhoneAuthContentState( - step = step.value, - isLoading = isLoading, - error = errorMessage, - phoneNumber = phoneNumberValue.value, - onPhoneNumberChange = { number -> - phoneNumberValue.value = number - }, - selectedCountry = selectedCountry.value, - onCountrySelected = { country -> - selectedCountry.value = country - }, - onSendCodeClick = { - coroutineScope.launch { - try { - val currentTime = System.currentTimeMillis() - val timeoutMs = provider.timeout * 1000 - val timeSinceLastVerification = verificationStartTime.value?.let { - currentTime - it - } ?: Long.MAX_VALUE - - // Check if the same phone number is being verified again within the cooldown period - val storedNumber = pendingVerificationPhoneNumber.value - val isSameNumber = storedNumber != null && fullPhoneNumber == storedNumber - - // Check cooldown: same number and still within timeout period - if (isSameNumber && timeSinceLastVerification < timeoutMs) { - // Calculate remaining cooldown time in seconds - val remainingCooldownSeconds = ((timeoutMs - timeSinceLastVerification) / 1000).coerceAtLeast(1) - val cooldownException = AuthException.PhoneVerificationCooldownException( - message = "Please wait ${remainingCooldownSeconds} second${if (remainingCooldownSeconds != 1L) "s" else ""} before verifying the same phone number again. The cooldown period is ${provider.timeout} seconds.", - cooldownSeconds = remainingCooldownSeconds - ) - // Update auth state to show the error - authUI.updateAuthState(AuthState.Error(cooldownException)) - throw cooldownException - } - - // Track the phone number and start time for cooldown checking - pendingVerificationPhoneNumber.value = fullPhoneNumber - verificationStartTime.value = currentTime - - authUI.verifyPhoneNumber( - provider = provider, - activity = activity, - phoneNumber = fullPhoneNumber, - ) - } catch (e: Exception) { - // Error will be handled by authState flow - } - } - }, - verificationCode = verificationCodeValue.value, - onVerificationCodeChange = { code -> - verificationCodeValue.value = code - }, - onVerifyCodeClick = { - coroutineScope.launch { - try { - verificationId.value?.let { id -> - authUI.submitVerificationCode( - context = context, - config = configuration, - verificationId = id, - code = verificationCodeValue.value - ) - } - } catch (e: Exception) { - // Error will be handled by authState flow - } - } - }, - fullPhoneNumber = fullPhoneNumber, - onResendCodeClick = { - if (resendTimerSeconds.intValue == 0) { - coroutineScope.launch { - try { - authUI.verifyPhoneNumber( - activity = activity, - provider = provider, - phoneNumber = fullPhoneNumber, - forceResendingToken = forceResendingToken.value, - ) - resendTimerSeconds.intValue = provider.timeout.toInt() // Restart timer - } catch (e: Exception) { - // Error will be handled by authState flow - } - } - } - }, - resendTimer = resendTimerSeconds.intValue, - onChangeNumberClick = { - step.value = PhoneAuthStep.EnterPhoneNumber - verificationCodeValue.value = "" - verificationId.value = null - forceResendingToken.value = null - resendTimerSeconds.intValue = 0 - } - ) - - if (content != null) { - content(state) - } else { - DefaultPhoneAuthContent( - configuration = configuration, - state = state, - onCancel = onCancel - ) - } -} - -@Composable -private fun DefaultPhoneAuthContent( - configuration: AuthUIConfiguration, - state: PhoneAuthContentState, - onCancel: () -> Unit, -) { - when (state.step) { - PhoneAuthStep.EnterPhoneNumber -> { - EnterPhoneNumberUI( - configuration = configuration, - isLoading = state.isLoading, - phoneNumber = state.phoneNumber, - selectedCountry = state.selectedCountry, - onPhoneNumberChange = state.onPhoneNumberChange, - onCountrySelected = state.onCountrySelected, - onSendCodeClick = state.onSendCodeClick, - onNavigateBack = onCancel - ) - } - - PhoneAuthStep.EnterVerificationCode -> { - EnterVerificationCodeUI( - configuration = configuration, - isLoading = state.isLoading, - verificationCode = state.verificationCode, - fullPhoneNumber = state.fullPhoneNumber, - resendTimer = state.resendTimer, - onVerificationCodeChange = state.onVerificationCodeChange, - onVerifyCodeClick = state.onVerifyCodeClick, - onResendCodeClick = state.onResendCodeClick, - onChangeNumberClick = state.onChangeNumberClick, - onNavigateBack = onCancel - ) - } - } -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt index af1c751716..e94d588797 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/EmailLinkPersistenceManager.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -86,7 +87,6 @@ object EmailLinkPersistenceManager { val credentialForLinking = if (providerType != null && idToken != null) { when (providerType) { Provider.GOOGLE -> GoogleAuthProvider.getCredential(idToken, accessToken) - Provider.FACEBOOK -> FacebookAuthProvider.getCredential(accessToken ?: "") else -> null } } else { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt deleted file mode 100644 index 9ca24116c9..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/PhoneNumberUtils.kt +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.util - -import androidx.annotation.RestrictTo -import com.battlelancer.seriesguide.backend.auth.util.CountryUtils - -/** - * Phone number validation utilities. - */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -object PhoneNumberUtils { - - /** - * Validates if a string starts with a valid dial code. - * Accepts dial codes like "+1" or phone numbers like "+14155552671". - * Does NOT validate the full phone number, only checks if it starts with a valid country code. - * - * @param number The dial code or phone number to validate (should start with "+") - * @return true if the string starts with a valid country dial code, false otherwise - */ - fun isValid(number: String): Boolean { - if (!number.startsWith("+")) return false - - // Try to extract country code from the beginning (1-3 digits) - val digitsOnly = number.drop(1).takeWhile { it.isDigit() } - if (digitsOnly.isEmpty()) return false - - // Check if any prefix (1-3 digits) is a valid dial code - for (length in 1..minOf(3, digitsOnly.length)) { - val dialCode = "+${digitsOnly.take(length)}" - if (CountryUtils.findByDialCode(dialCode).isNotEmpty()) { - return true - } - } - return false - } - - /** - * Validates if a country ISO code or dial code is valid. - * Accepts both ISO codes (e.g., "US", "us") and dial codes (e.g., "+1"). - * - * @param code The ISO 3166-1 alpha-2 country code or E.164 dial code - * @return true if the code is a valid ISO code or dial code, false otherwise - */ - fun isValidIso(code: String?): Boolean { - if (code == null) return false - - // Check if it's a valid ISO country code (e.g., "US", "GB") - if (CountryUtils.findByCountryCode(code) != null) { - return true - } - - // Check if it's a valid dial code (e.g., "+1", "+44") - if (code.startsWith("+")) { - return CountryUtils.findByDialCode(code).isNotEmpty() - } - - return false - } -} \ No newline at end of file diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt deleted file mode 100644 index 3832a569db..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/ProviderAvailability.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.util - -import androidx.annotation.RestrictTo - -/** - * Utility for checking the availability of authentication providers at runtime. - */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -object ProviderAvailability { - - /** - * Checks if Facebook authentication is available. - * Returns true if the Facebook SDK is present in the classpath. - */ - val IS_FACEBOOK_AVAILABLE: Boolean = classExists("com.facebook.login.LoginManager") - - private fun classExists(className: String): Boolean { - return try { - Class.forName(className) - true - } catch (e: ClassNotFoundException) { - false - } - } -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c9716c6c4..dd9d5edd07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -639,34 +639,14 @@ Network error, check your internet connection. - Google - Facebook - Twitter - GitHub - Phone Email "Auth method picker logo" Sign in with Google - Sign in with Facebook - Sign in with X - Sign in with GitHub Sign in with email - Sign in with phone - Continue as guest - Sign in with Apple - Sign in with Microsoft - Sign in with Yahoo Continue with Google - Continue with Facebook - Continue with X - Continue with GitHub Continue with email - Continue with phone - Continue with Apple - Continue with Microsoft - Continue with Yahoo Signed in as %1$s @@ -729,10 +709,6 @@ Next Email - Phone Number - Country - Select a country - Select for country e.g. +1, "US" Password Confirm Password New password @@ -826,8 +802,6 @@ - Enter your phone number - Enter a valid phone number Enter the 6-digit code we sent to Resend code in %1$s Verify your phone number @@ -839,8 +813,6 @@ Sign in with phone number Phone number automatically verified Resend Code - Verify Phone Number - Use a different phone number By tapping "%1$s", an SMS may be sent. Message & data rates may apply. By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. From 68c0d27d9ccc04f53277cd26d6dbcfa24bb54f84 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 09:36:54 +0100 Subject: [PATCH 013/150] Auth: remove sms factor --- .../seriesguide/backend/auth/AuthException.kt | 53 +-- .../auth/configuration/MfaConfiguration.kt | 4 +- .../backend/auth/configuration/MfaFactor.kt | 6 - .../string_provider/AuthUIStringProvider.kt | 46 --- .../DefaultAuthUIStringProvider.kt | 41 +- .../backend/auth/data/Countries.kt | 252 ------------ .../backend/auth/data/CountryData.kt | 54 --- .../auth/mfa/MfaChallengeContentState.kt | 13 - .../auth/mfa/MfaEnrollmentContentState.kt | 22 -- .../backend/auth/mfa/MfaEnrollmentStep.kt | 9 - .../backend/auth/mfa/SmsEnrollmentHandler.kt | 368 ------------------ .../auth/ui/components/ErrorRecoveryDialog.kt | 20 +- .../auth/ui/screens/FirebaseAuthScreen.kt | 3 - .../auth/ui/screens/MfaChallengeDefaults.kt | 60 +-- .../auth/ui/screens/MfaChallengeScreen.kt | 132 +------ .../auth/ui/screens/MfaEnrollmentDefaults.kt | 51 +-- .../auth/ui/screens/MfaEnrollmentScreen.kt | 109 +----- .../backend/auth/util/CountryUtils.kt | 149 ------- app/src/main/res/values/strings.xml | 27 +- 19 files changed, 37 insertions(+), 1382 deletions(-) delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt delete mode 100644 app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt index 793cec7ab8..13e4098854 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/AuthException.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -129,36 +130,6 @@ abstract class AuthException( val email: String? = null ) : AuthException(message, cause) - /** - * Too many requests have been made to the server. - * - * This exception is thrown when the client has made too many requests - * in a short period and needs to wait before making additional requests. - * - * @property message The detailed error message - * @property cause The underlying [Throwable] that caused this exception - */ - class TooManyRequestsException( - message: String, - cause: Throwable? = null - ) : AuthException(message, cause) - - /** - * Phone verification is in cooldown period for the same phone number. - * - * This exception is thrown when attempting to verify the same phone number - * again before the cooldown period (timeout) has expired. - * - * @property message The detailed error message - * @property cooldownSeconds The number of seconds remaining in the cooldown period - * @property cause The underlying [Throwable] that caused this exception - */ - class PhoneVerificationCooldownException( - message: String, - val cooldownSeconds: Long, - cause: Throwable? = null - ) : AuthException(message, cause) - /** * Multi-Factor Authentication is required to proceed. * @@ -316,7 +287,6 @@ abstract class AuthException( * - [FirebaseAuthInvalidUserException] → [UserNotFoundException] * - [FirebaseAuthWeakPasswordException] → [WeakPasswordException] * - [FirebaseAuthUserCollisionException] → [EmailAlreadyInUseException] - * - [FirebaseAuthException] with ERROR_TOO_MANY_REQUESTS → [TooManyRequestsException] * - [FirebaseAuthMultiFactorException] → [MfaRequiredException] * - Other exceptions → [UnknownException] * @@ -419,20 +389,11 @@ abstract class AuthException( } is FirebaseAuthException -> { - // Handle FirebaseAuthException and check for specific error codes - when (firebaseException.errorCode) { - "ERROR_TOO_MANY_REQUESTS" -> TooManyRequestsException( - message = firebaseException.message - ?: "Too many requests. Please try again later", - cause = firebaseException - ) - - else -> UnknownException( - message = firebaseException.message - ?: "An unknown authentication error occurred", - cause = firebaseException - ) - } + UnknownException( + message = firebaseException.message + ?: "An unknown authentication error occurred", + cause = firebaseException + ) } is FirebaseException -> { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt index f5986bed53..2967b57509 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaConfiguration.kt @@ -13,7 +13,7 @@ package com.battlelancer.seriesguide.backend.auth.configuration * and whether recovery codes are generated. * * @property allowedFactors List of MFA factors that users are permitted to enroll in. - * Defaults to [MfaFactor.Sms, MfaFactor.Totp]. + * Defaults to [MfaFactor.Totp]. * @property requireEnrollment Whether MFA enrollment is mandatory for all users. * When true, users must enroll in at least one MFA factor. * Defaults to false. @@ -22,7 +22,7 @@ package com.battlelancer.seriesguide.backend.auth.configuration * as a backup authentication method. Defaults to true. */ class MfaConfiguration( - val allowedFactors: List = listOf(MfaFactor.Sms, MfaFactor.Totp), + val allowedFactors: List = listOf(MfaFactor.Totp), val requireEnrollment: Boolean = false, val enableRecoveryCodes: Boolean = true ) { diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt index ace7407a53..fceb759084 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/MfaFactor.kt @@ -11,12 +11,6 @@ package com.battlelancer.seriesguide.backend.auth.configuration * for enrollment and verification. */ enum class MfaFactor { - /** - * SMS-based authentication factor. - * Users receive a verification code via text message to their registered phone number. - */ - Sms, - /** * Time-based One-Time Password (TOTP) authentication factor. * Users generate verification codes using an authenticator app. diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt index 95baa37c11..2c11700715 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/AuthUIStringProvider.kt @@ -166,27 +166,6 @@ interface AuthUIStringProvider { /** Message shown when email mismatch occurs */ val emailMismatchMessage: String - /** Resend code link text */ - val resendCode: String - - /** Resend code with timer */ - fun resendCodeTimer(timeFormatted: String): String - - /** Verifying progress text */ - val verifying: String - - /** Wrong verification code error */ - val incorrectCodeDialogBody: String - - /** SMS terms of service warning */ - val smsTermsOfService: String - - /** Enter verification code title with phone number */ - fun enterVerificationCodeTitle(phoneNumber: String): String - - /** Verification code hint */ - val verificationCodeHint: String - /** Missing verification code error */ val missingVerificationCode: String @@ -249,9 +228,6 @@ interface AuthUIStringProvider { /** Action text for confirming verification. */ val verifyAction: String - /** Action text for choosing a different factor during MFA challenge. */ - val useDifferentMethodAction: String - /** Action text for confirming recovery codes have been saved. */ val recoveryCodesSavedAction: String @@ -279,9 +255,6 @@ interface AuthUIStringProvider { /** Message shown when all factors are already enrolled. */ val mfaAllMethodsEnrolledMessage: String - /** Label for SMS MFA factor. */ - val smsAuthenticationLabel: String - /** Label for authenticator-app MFA factor. */ val totpAuthenticationLabel: String @@ -325,9 +298,6 @@ interface AuthUIStringProvider { /** Email already in use recovery message */ val emailAlreadyInUseRecoveryMessage: String - /** Too many requests recovery message */ - val tooManyRequestsRecoveryMessage: String - /** MFA required recovery message */ val mfaRequiredRecoveryMessage: String @@ -344,9 +314,6 @@ interface AuthUIStringProvider { /** Title for MFA factor selection step */ val mfaStepSelectFactorTitle: String - /** Title for SMS MFA configuration step */ - val mfaStepConfigureSmsTitle: String - /** Title for TOTP MFA configuration step */ val mfaStepConfigureTotpTitle: String @@ -360,15 +327,9 @@ interface AuthUIStringProvider { /** Helper text for selecting MFA factor */ val mfaStepSelectFactorHelper: String - /** Helper text for SMS configuration */ - val mfaStepConfigureSmsHelper: String - /** Helper text for TOTP configuration */ val mfaStepConfigureTotpHelper: String - /** Helper text for SMS verification */ - val mfaStepVerifyFactorSmsHelper: String - /** Helper text for TOTP verification */ val mfaStepVerifyFactorTotpHelper: String @@ -378,13 +339,6 @@ interface AuthUIStringProvider { /** Helper text for recovery codes */ val mfaStepShowRecoveryCodesHelper: String - // MFA Enrollment Screen Titles - /** Title for MFA phone number enrollment screen (top app bar) */ - val mfaEnrollmentEnterPhoneNumber: String - - /** Title for MFA SMS verification screen (top app bar) */ - val mfaEnrollmentVerifySmsCode: String - // MFA Error Messages /** Error message when MFA enrollment requires recent authentication */ val mfaErrorRecentLoginRequired: String diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt index 6a66dfa19b..bd207c4cf5 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/string_provider/DefaultAuthUIStringProvider.kt @@ -165,30 +165,11 @@ class DefaultAuthUIStringProvider( override val emailMismatchMessage: String get() = localizedContext.getString(R.string.fui_error_unknown) - override val resendCode: String - get() = localizedContext.getString(R.string.fui_resend_code) - - override fun resendCodeTimer(timeFormatted: String): String = - localizedContext.getString(R.string.fui_resend_code_in, timeFormatted) - - override val verifying: String - get() = localizedContext.getString(R.string.fui_verifying) - override val incorrectCodeDialogBody: String - get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) - override val smsTermsOfService: String - get() = localizedContext.getString(R.string.fui_sms_terms_of_service) - - override fun enterVerificationCodeTitle(phoneNumber: String): String = - localizedContext.getString(R.string.fui_enter_confirmation_code) + " " + phoneNumber - - override val verificationCodeHint: String - get() = localizedContext.getString(R.string.fui_enter_confirmation_code) - override val missingVerificationCode: String get() = localizedContext.getString(R.string.fui_required_field) override val invalidVerificationCode: String - get() = localizedContext.getString(R.string.fui_incorrect_code_dialog_body) + get() = localizedContext.getString(R.string.fui_mfa_incorrect_code) /** * Multi-Factor Authentication Strings @@ -252,9 +233,6 @@ class DefaultAuthUIStringProvider( override val verifyAction: String get() = localizedContext.getString(R.string.fui_verify_action) - override val useDifferentMethodAction: String - get() = localizedContext.getString(R.string.fui_use_different_method_action) - override val recoveryCodesSavedAction: String get() = localizedContext.getString(R.string.fui_recovery_codes_saved_action) @@ -282,9 +260,6 @@ class DefaultAuthUIStringProvider( override val mfaAllMethodsEnrolledMessage: String get() = localizedContext.getString(R.string.fui_mfa_all_methods_enrolled_message) - override val smsAuthenticationLabel: String - get() = localizedContext.getString(R.string.fui_mfa_label_sms_authentication) - override val totpAuthenticationLabel: String get() = localizedContext.getString(R.string.fui_mfa_label_totp_authentication) @@ -322,8 +297,6 @@ class DefaultAuthUIStringProvider( ) override val emailAlreadyInUseRecoveryMessage: String get() = localizedContext.getString(R.string.fui_email_account_creation_error) - override val tooManyRequestsRecoveryMessage: String - get() = localizedContext.getString(R.string.fui_error_too_many_attempts) override val mfaRequiredRecoveryMessage: String get() = localizedContext.getString(R.string.fui_error_mfa_required_message) override val accountLinkingRequiredRecoveryMessage: String @@ -338,8 +311,6 @@ class DefaultAuthUIStringProvider( */ override val mfaStepSelectFactorTitle: String get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_title) - override val mfaStepConfigureSmsTitle: String - get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_title) override val mfaStepConfigureTotpTitle: String get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_title) override val mfaStepVerifyFactorTitle: String @@ -352,12 +323,8 @@ class DefaultAuthUIStringProvider( */ override val mfaStepSelectFactorHelper: String get() = localizedContext.getString(R.string.fui_mfa_step_select_factor_helper) - override val mfaStepConfigureSmsHelper: String - get() = localizedContext.getString(R.string.fui_mfa_step_configure_sms_helper) override val mfaStepConfigureTotpHelper: String get() = localizedContext.getString(R.string.fui_mfa_step_configure_totp_helper) - override val mfaStepVerifyFactorSmsHelper: String - get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_sms_helper) override val mfaStepVerifyFactorTotpHelper: String get() = localizedContext.getString(R.string.fui_mfa_step_verify_factor_totp_helper) override val mfaStepVerifyFactorGenericHelper: String @@ -365,12 +332,6 @@ class DefaultAuthUIStringProvider( override val mfaStepShowRecoveryCodesHelper: String get() = localizedContext.getString(R.string.fui_mfa_step_show_recovery_codes_helper) - // MFA Enrollment Screen Titles - override val mfaEnrollmentEnterPhoneNumber: String - get() = localizedContext.getString(R.string.fui_mfa_enrollment_enter_phone_number) - override val mfaEnrollmentVerifySmsCode: String - get() = localizedContext.getString(R.string.fui_mfa_enrollment_verify_sms_code) - // MFA Error Messages override val mfaErrorRecentLoginRequired: String get() = localizedContext.getString(R.string.fui_mfa_error_recent_login_required) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt deleted file mode 100644 index 241986877b..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/Countries.kt +++ /dev/null @@ -1,252 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.data - -/** - * Complete list of countries with their dial codes and ISO country codes. - * Auto-generated from ISO 3166-1 standard. - */ -val ALL_COUNTRIES: List = listOf( - CountryData("Afghanistan", "+93", "AF", countryCodeToFlagEmoji("AF")), - CountryData("Albania", "+355", "AL", countryCodeToFlagEmoji("AL")), - CountryData("Algeria", "+213", "DZ", countryCodeToFlagEmoji("DZ")), - CountryData("American Samoa", "+684", "AS", countryCodeToFlagEmoji("AS")), - CountryData("Andorra", "+376", "AD", countryCodeToFlagEmoji("AD")), - CountryData("Angola", "+244", "AO", countryCodeToFlagEmoji("AO")), - CountryData("Anguilla", "+264", "AI", countryCodeToFlagEmoji("AI")), - CountryData("Antigua and Barbuda", "+268", "AG", countryCodeToFlagEmoji("AG")), - CountryData("Argentina", "+54", "AR", countryCodeToFlagEmoji("AR")), - CountryData("Armenia", "+374", "AM", countryCodeToFlagEmoji("AM")), - CountryData("Aruba", "+297", "AW", countryCodeToFlagEmoji("AW")), - CountryData("Australia", "+61", "AU", countryCodeToFlagEmoji("AU")), - CountryData("Austria", "+43", "AT", countryCodeToFlagEmoji("AT")), - CountryData("Azerbaijan", "+994", "AZ", countryCodeToFlagEmoji("AZ")), - CountryData("Bahamas", "+242", "BS", countryCodeToFlagEmoji("BS")), - CountryData("Bahrain", "+973", "BH", countryCodeToFlagEmoji("BH")), - CountryData("Bangladesh", "+880", "BD", countryCodeToFlagEmoji("BD")), - CountryData("Barbados", "+246", "BB", countryCodeToFlagEmoji("BB")), - CountryData("Belarus", "+375", "BY", countryCodeToFlagEmoji("BY")), - CountryData("Belgium", "+32", "BE", countryCodeToFlagEmoji("BE")), - CountryData("Belize", "+501", "BZ", countryCodeToFlagEmoji("BZ")), - CountryData("Benin", "+229", "BJ", countryCodeToFlagEmoji("BJ")), - CountryData("Bermuda", "+441", "BM", countryCodeToFlagEmoji("BM")), - CountryData("Bhutan", "+975", "BT", countryCodeToFlagEmoji("BT")), - CountryData("Bolivia", "+591", "BO", countryCodeToFlagEmoji("BO")), - CountryData("Bosnia and Herzegovina", "+387", "BA", countryCodeToFlagEmoji("BA")), - CountryData("Botswana", "+267", "BW", countryCodeToFlagEmoji("BW")), - CountryData("Brazil", "+55", "BR", countryCodeToFlagEmoji("BR")), - CountryData("British Indian Ocean Territory", "+246", "IO", countryCodeToFlagEmoji("IO")), - CountryData("Brunei", "+673", "BN", countryCodeToFlagEmoji("BN")), - CountryData("Bulgaria", "+359", "BG", countryCodeToFlagEmoji("BG")), - CountryData("Burkina Faso", "+226", "BF", countryCodeToFlagEmoji("BF")), - CountryData("Burundi", "+257", "BI", countryCodeToFlagEmoji("BI")), - CountryData("Cambodia", "+855", "KH", countryCodeToFlagEmoji("KH")), - CountryData("Cameroon", "+237", "CM", countryCodeToFlagEmoji("CM")), - CountryData("Canada", "+1", "CA", countryCodeToFlagEmoji("CA")), - CountryData("Cape Verde", "+238", "CV", countryCodeToFlagEmoji("CV")), - CountryData("Cayman Islands", "+345", "KY", countryCodeToFlagEmoji("KY")), - CountryData("Central African Republic", "+236", "CF", countryCodeToFlagEmoji("CF")), - CountryData("Chad", "+235", "TD", countryCodeToFlagEmoji("TD")), - CountryData("Chile", "+56", "CL", countryCodeToFlagEmoji("CL")), - CountryData("China", "+86", "CN", countryCodeToFlagEmoji("CN")), - CountryData("Colombia", "+57", "CO", countryCodeToFlagEmoji("CO")), - CountryData("Comoros", "+269", "KM", countryCodeToFlagEmoji("KM")), - CountryData("Congo", "+242", "CG", countryCodeToFlagEmoji("CG")), - CountryData("Congo (DRC)", "+243", "CD", countryCodeToFlagEmoji("CD")), - CountryData("Cook Islands", "+682", "CK", countryCodeToFlagEmoji("CK")), - CountryData("Costa Rica", "+506", "CR", countryCodeToFlagEmoji("CR")), - CountryData("Côte d'Ivoire", "+225", "CI", countryCodeToFlagEmoji("CI")), - CountryData("Croatia", "+385", "HR", countryCodeToFlagEmoji("HR")), - CountryData("Cuba", "+53", "CU", countryCodeToFlagEmoji("CU")), - CountryData("Curaçao", "+599", "CW", countryCodeToFlagEmoji("CW")), - CountryData("Cyprus", "+357", "CY", countryCodeToFlagEmoji("CY")), - CountryData("Czech Republic", "+420", "CZ", countryCodeToFlagEmoji("CZ")), - CountryData("Denmark", "+45", "DK", countryCodeToFlagEmoji("DK")), - CountryData("Djibouti", "+253", "DJ", countryCodeToFlagEmoji("DJ")), - CountryData("Dominica", "+767", "DM", countryCodeToFlagEmoji("DM")), - CountryData("Dominican Republic", "+809", "DO", countryCodeToFlagEmoji("DO")), - CountryData("Ecuador", "+593", "EC", countryCodeToFlagEmoji("EC")), - CountryData("Egypt", "+20", "EG", countryCodeToFlagEmoji("EG")), - CountryData("El Salvador", "+503", "SV", countryCodeToFlagEmoji("SV")), - CountryData("Equatorial Guinea", "+240", "GQ", countryCodeToFlagEmoji("GQ")), - CountryData("Eritrea", "+291", "ER", countryCodeToFlagEmoji("ER")), - CountryData("Estonia", "+372", "EE", countryCodeToFlagEmoji("EE")), - CountryData("Ethiopia", "+251", "ET", countryCodeToFlagEmoji("ET")), - CountryData("Falkland Islands", "+500", "FK", countryCodeToFlagEmoji("FK")), - CountryData("Faroe Islands", "+298", "FO", countryCodeToFlagEmoji("FO")), - CountryData("Fiji", "+679", "FJ", countryCodeToFlagEmoji("FJ")), - CountryData("Finland", "+358", "FI", countryCodeToFlagEmoji("FI")), - CountryData("France", "+33", "FR", countryCodeToFlagEmoji("FR")), - CountryData("French Guiana", "+594", "GF", countryCodeToFlagEmoji("GF")), - CountryData("French Polynesia", "+689", "PF", countryCodeToFlagEmoji("PF")), - CountryData("Gabon", "+241", "GA", countryCodeToFlagEmoji("GA")), - CountryData("Gambia", "+220", "GM", countryCodeToFlagEmoji("GM")), - CountryData("Georgia", "+995", "GE", countryCodeToFlagEmoji("GE")), - CountryData("Germany", "+49", "DE", countryCodeToFlagEmoji("DE")), - CountryData("Ghana", "+233", "GH", countryCodeToFlagEmoji("GH")), - CountryData("Gibraltar", "+350", "GI", countryCodeToFlagEmoji("GI")), - CountryData("Greece", "+30", "GR", countryCodeToFlagEmoji("GR")), - CountryData("Greenland", "+299", "GL", countryCodeToFlagEmoji("GL")), - CountryData("Grenada", "+473", "GD", countryCodeToFlagEmoji("GD")), - CountryData("Guadeloupe", "+590", "GP", countryCodeToFlagEmoji("GP")), - CountryData("Guam", "+671", "GU", countryCodeToFlagEmoji("GU")), - CountryData("Guatemala", "+502", "GT", countryCodeToFlagEmoji("GT")), - CountryData("Guernsey", "+1481", "GG", countryCodeToFlagEmoji("GG")), - CountryData("Guinea", "+224", "GN", countryCodeToFlagEmoji("GN")), - CountryData("Guinea-Bissau", "+245", "GW", countryCodeToFlagEmoji("GW")), - CountryData("Guyana", "+592", "GY", countryCodeToFlagEmoji("GY")), - CountryData("Haiti", "+509", "HT", countryCodeToFlagEmoji("HT")), - CountryData("Honduras", "+504", "HN", countryCodeToFlagEmoji("HN")), - CountryData("Hong Kong", "+852", "HK", countryCodeToFlagEmoji("HK")), - CountryData("Hungary", "+36", "HU", countryCodeToFlagEmoji("HU")), - CountryData("Iceland", "+354", "IS", countryCodeToFlagEmoji("IS")), - CountryData("India", "+91", "IN", countryCodeToFlagEmoji("IN")), - CountryData("Indonesia", "+62", "ID", countryCodeToFlagEmoji("ID")), - CountryData("Iran", "+98", "IR", countryCodeToFlagEmoji("IR")), - CountryData("Iraq", "+964", "IQ", countryCodeToFlagEmoji("IQ")), - CountryData("Ireland", "+353", "IE", countryCodeToFlagEmoji("IE")), - CountryData("Isle of Man", "+44", "IM", countryCodeToFlagEmoji("IM")), - CountryData("Israel", "+972", "IL", countryCodeToFlagEmoji("IL")), - CountryData("Italy", "+39", "IT", countryCodeToFlagEmoji("IT")), - CountryData("Jamaica", "+876", "JM", countryCodeToFlagEmoji("JM")), - CountryData("Japan", "+81", "JP", countryCodeToFlagEmoji("JP")), - CountryData("Jersey", "+44", "JE", countryCodeToFlagEmoji("JE")), - CountryData("Jordan", "+962", "JO", countryCodeToFlagEmoji("JO")), - CountryData("Kazakhstan", "+7", "KZ", countryCodeToFlagEmoji("KZ")), - CountryData("Kenya", "+254", "KE", countryCodeToFlagEmoji("KE")), - CountryData("Kiribati", "+686", "KI", countryCodeToFlagEmoji("KI")), - CountryData("Kosovo", "+383", "XK", countryCodeToFlagEmoji("XK")), - CountryData("Kuwait", "+965", "KW", countryCodeToFlagEmoji("KW")), - CountryData("Kyrgyzstan", "+996", "KG", countryCodeToFlagEmoji("KG")), - CountryData("Laos", "+856", "LA", countryCodeToFlagEmoji("LA")), - CountryData("Latvia", "+371", "LV", countryCodeToFlagEmoji("LV")), - CountryData("Lebanon", "+961", "LB", countryCodeToFlagEmoji("LB")), - CountryData("Lesotho", "+266", "LS", countryCodeToFlagEmoji("LS")), - CountryData("Liberia", "+231", "LR", countryCodeToFlagEmoji("LR")), - CountryData("Libya", "+218", "LY", countryCodeToFlagEmoji("LY")), - CountryData("Liechtenstein", "+423", "LI", countryCodeToFlagEmoji("LI")), - CountryData("Lithuania", "+370", "LT", countryCodeToFlagEmoji("LT")), - CountryData("Luxembourg", "+352", "LU", countryCodeToFlagEmoji("LU")), - CountryData("Macao", "+853", "MO", countryCodeToFlagEmoji("MO")), - CountryData("Macedonia", "+389", "MK", countryCodeToFlagEmoji("MK")), - CountryData("Madagascar", "+261", "MG", countryCodeToFlagEmoji("MG")), - CountryData("Malawi", "+265", "MW", countryCodeToFlagEmoji("MW")), - CountryData("Malaysia", "+60", "MY", countryCodeToFlagEmoji("MY")), - CountryData("Maldives", "+960", "MV", countryCodeToFlagEmoji("MV")), - CountryData("Mali", "+223", "ML", countryCodeToFlagEmoji("ML")), - CountryData("Malta", "+356", "MT", countryCodeToFlagEmoji("MT")), - CountryData("Marshall Islands", "+692", "MH", countryCodeToFlagEmoji("MH")), - CountryData("Martinique", "+596", "MQ", countryCodeToFlagEmoji("MQ")), - CountryData("Mauritania", "+222", "MR", countryCodeToFlagEmoji("MR")), - CountryData("Mauritius", "+230", "MU", countryCodeToFlagEmoji("MU")), - CountryData("Mayotte", "+262", "YT", countryCodeToFlagEmoji("YT")), - CountryData("Mexico", "+52", "MX", countryCodeToFlagEmoji("MX")), - CountryData("Micronesia", "+691", "FM", countryCodeToFlagEmoji("FM")), - CountryData("Moldova", "+373", "MD", countryCodeToFlagEmoji("MD")), - CountryData("Monaco", "+377", "MC", countryCodeToFlagEmoji("MC")), - CountryData("Mongolia", "+976", "MN", countryCodeToFlagEmoji("MN")), - CountryData("Montenegro", "+382", "ME", countryCodeToFlagEmoji("ME")), - CountryData("Montserrat", "+664", "MS", countryCodeToFlagEmoji("MS")), - CountryData("Morocco", "+212", "MA", countryCodeToFlagEmoji("MA")), - CountryData("Mozambique", "+258", "MZ", countryCodeToFlagEmoji("MZ")), - CountryData("Myanmar", "+95", "MM", countryCodeToFlagEmoji("MM")), - CountryData("Namibia", "+264", "NA", countryCodeToFlagEmoji("NA")), - CountryData("Nauru", "+674", "NR", countryCodeToFlagEmoji("NR")), - CountryData("Nepal", "+977", "NP", countryCodeToFlagEmoji("NP")), - CountryData("Netherlands", "+31", "NL", countryCodeToFlagEmoji("NL")), - CountryData("New Caledonia", "+687", "NC", countryCodeToFlagEmoji("NC")), - CountryData("New Zealand", "+64", "NZ", countryCodeToFlagEmoji("NZ")), - CountryData("Nicaragua", "+505", "NI", countryCodeToFlagEmoji("NI")), - CountryData("Niger", "+227", "NE", countryCodeToFlagEmoji("NE")), - CountryData("Nigeria", "+234", "NG", countryCodeToFlagEmoji("NG")), - CountryData("Niue", "+683", "NU", countryCodeToFlagEmoji("NU")), - CountryData("Norfolk Island", "+672", "NF", countryCodeToFlagEmoji("NF")), - CountryData("North Korea", "+850", "KP", countryCodeToFlagEmoji("KP")), - CountryData("Northern Mariana Islands", "+670", "MP", countryCodeToFlagEmoji("MP")), - CountryData("Norway", "+47", "NO", countryCodeToFlagEmoji("NO")), - CountryData("Oman", "+968", "OM", countryCodeToFlagEmoji("OM")), - CountryData("Pakistan", "+92", "PK", countryCodeToFlagEmoji("PK")), - CountryData("Palau", "+680", "PW", countryCodeToFlagEmoji("PW")), - CountryData("Palestine", "+970", "PS", countryCodeToFlagEmoji("PS")), - CountryData("Panama", "+507", "PA", countryCodeToFlagEmoji("PA")), - CountryData("Papua New Guinea", "+675", "PG", countryCodeToFlagEmoji("PG")), - CountryData("Paraguay", "+595", "PY", countryCodeToFlagEmoji("PY")), - CountryData("Peru", "+51", "PE", countryCodeToFlagEmoji("PE")), - CountryData("Philippines", "+63", "PH", countryCodeToFlagEmoji("PH")), - CountryData("Poland", "+48", "PL", countryCodeToFlagEmoji("PL")), - CountryData("Portugal", "+351", "PT", countryCodeToFlagEmoji("PT")), - CountryData("Puerto Rico", "+787", "PR", countryCodeToFlagEmoji("PR")), - CountryData("Qatar", "+974", "QA", countryCodeToFlagEmoji("QA")), - CountryData("Réunion", "+262", "RE", countryCodeToFlagEmoji("RE")), - CountryData("Romania", "+40", "RO", countryCodeToFlagEmoji("RO")), - CountryData("Russia", "+7", "RU", countryCodeToFlagEmoji("RU")), - CountryData("Rwanda", "+250", "RW", countryCodeToFlagEmoji("RW")), - CountryData("Saint Barthélemy", "+590", "BL", countryCodeToFlagEmoji("BL")), - CountryData("Saint Helena", "+290", "SH", countryCodeToFlagEmoji("SH")), - CountryData("Saint Kitts and Nevis", "+869", "KN", countryCodeToFlagEmoji("KN")), - CountryData("Saint Lucia", "+758", "LC", countryCodeToFlagEmoji("LC")), - CountryData("Saint Martin", "+590", "MF", countryCodeToFlagEmoji("MF")), - CountryData("Saint Pierre and Miquelon", "+508", "PM", countryCodeToFlagEmoji("PM")), - CountryData("Saint Vincent and the Grenadines", "+784", "VC", countryCodeToFlagEmoji("VC")), - CountryData("Samoa", "+685", "WS", countryCodeToFlagEmoji("WS")), - CountryData("San Marino", "+378", "SM", countryCodeToFlagEmoji("SM")), - CountryData("Sao Tome and Principe", "+239", "ST", countryCodeToFlagEmoji("ST")), - CountryData("Saudi Arabia", "+966", "SA", countryCodeToFlagEmoji("SA")), - CountryData("Senegal", "+221", "SN", countryCodeToFlagEmoji("SN")), - CountryData("Serbia", "+381", "RS", countryCodeToFlagEmoji("RS")), - CountryData("Seychelles", "+248", "SC", countryCodeToFlagEmoji("SC")), - CountryData("Sierra Leone", "+232", "SL", countryCodeToFlagEmoji("SL")), - CountryData("Singapore", "+65", "SG", countryCodeToFlagEmoji("SG")), - CountryData("Sint Maarten", "+599", "SX", countryCodeToFlagEmoji("SX")), - CountryData("Slovakia", "+421", "SK", countryCodeToFlagEmoji("SK")), - CountryData("Slovenia", "+386", "SI", countryCodeToFlagEmoji("SI")), - CountryData("Solomon Islands", "+677", "SB", countryCodeToFlagEmoji("SB")), - CountryData("Somalia", "+252", "SO", countryCodeToFlagEmoji("SO")), - CountryData("South Africa", "+27", "ZA", countryCodeToFlagEmoji("ZA")), - CountryData("South Korea", "+82", "KR", countryCodeToFlagEmoji("KR")), - CountryData("South Sudan", "+211", "SS", countryCodeToFlagEmoji("SS")), - CountryData("Spain", "+34", "ES", countryCodeToFlagEmoji("ES")), - CountryData("Sri Lanka", "+94", "LK", countryCodeToFlagEmoji("LK")), - CountryData("Sudan", "+249", "SD", countryCodeToFlagEmoji("SD")), - CountryData("Suriname", "+597", "SR", countryCodeToFlagEmoji("SR")), - CountryData("Swaziland", "+268", "SZ", countryCodeToFlagEmoji("SZ")), - CountryData("Sweden", "+46", "SE", countryCodeToFlagEmoji("SE")), - CountryData("Switzerland", "+41", "CH", countryCodeToFlagEmoji("CH")), - CountryData("Syria", "+963", "SY", countryCodeToFlagEmoji("SY")), - CountryData("Taiwan", "+886", "TW", countryCodeToFlagEmoji("TW")), - CountryData("Tajikistan", "+992", "TJ", countryCodeToFlagEmoji("TJ")), - CountryData("Tanzania", "+255", "TZ", countryCodeToFlagEmoji("TZ")), - CountryData("Thailand", "+66", "TH", countryCodeToFlagEmoji("TH")), - CountryData("Timor-Leste", "+670", "TL", countryCodeToFlagEmoji("TL")), - CountryData("Togo", "+228", "TG", countryCodeToFlagEmoji("TG")), - CountryData("Tokelau", "+690", "TK", countryCodeToFlagEmoji("TK")), - CountryData("Tonga", "+676", "TO", countryCodeToFlagEmoji("TO")), - CountryData("Trinidad and Tobago", "+868", "TT", countryCodeToFlagEmoji("TT")), - CountryData("Tunisia", "+216", "TN", countryCodeToFlagEmoji("TN")), - CountryData("Turkey", "+90", "TR", countryCodeToFlagEmoji("TR")), - CountryData("Turkmenistan", "+993", "TM", countryCodeToFlagEmoji("TM")), - CountryData("Turks and Caicos Islands", "+649", "TC", countryCodeToFlagEmoji("TC")), - CountryData("Tuvalu", "+688", "TV", countryCodeToFlagEmoji("TV")), - CountryData("Uganda", "+256", "UG", countryCodeToFlagEmoji("UG")), - CountryData("Ukraine", "+380", "UA", countryCodeToFlagEmoji("UA")), - CountryData("United Arab Emirates", "+971", "AE", countryCodeToFlagEmoji("AE")), - CountryData("United Kingdom", "+44", "GB", countryCodeToFlagEmoji("GB")), - CountryData("United States", "+1", "US", countryCodeToFlagEmoji("US")), - CountryData("Uruguay", "+598", "UY", countryCodeToFlagEmoji("UY")), - CountryData("Uzbekistan", "+998", "UZ", countryCodeToFlagEmoji("UZ")), - CountryData("Vanuatu", "+678", "VU", countryCodeToFlagEmoji("VU")), - CountryData("Vatican City", "+379", "VA", countryCodeToFlagEmoji("VA")), - CountryData("Venezuela", "+58", "VE", countryCodeToFlagEmoji("VE")), - CountryData("Vietnam", "+84", "VN", countryCodeToFlagEmoji("VN")), - CountryData("Virgin Islands (British)", "+284", "VG", countryCodeToFlagEmoji("VG")), - CountryData("Virgin Islands (U.S.)", "+340", "VI", countryCodeToFlagEmoji("VI")), - CountryData("Wallis and Futuna", "+681", "WF", countryCodeToFlagEmoji("WF")), - CountryData("Western Sahara", "+212", "EH", countryCodeToFlagEmoji("EH")), - CountryData("Yemen", "+967", "YE", countryCodeToFlagEmoji("YE")), - CountryData("Zambia", "+260", "ZM", countryCodeToFlagEmoji("ZM")), - CountryData("Zimbabwe", "+263", "ZW", countryCodeToFlagEmoji("ZW")) -) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt deleted file mode 100644 index 49b16d6380..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/data/CountryData.kt +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.data - -/** - * Represents country information for phone number authentication. - * - * @property name The display name of the country (e.g., "United States"). - * @property dialCode The international dialing code (e.g., "+1"). - * @property countryCode The ISO 3166-1 alpha-2 country code (e.g., "US"). - * @property flagEmoji The flag emoji for the country (e.g., "🇺🇸"). - */ -data class CountryData( - val name: String, - val dialCode: String, - val countryCode: String, - val flagEmoji: String -) { - /** - * Returns a formatted display string combining flag emoji and country name. - */ - fun getDisplayName(): String = "$flagEmoji $name" - - /** - * Returns a formatted string with dial code. - */ - fun getDisplayNameWithDialCode(): String = "$flagEmoji $name ($dialCode)" -} - -/** - * Converts an ISO 3166-1 alpha-2 country code to its corresponding flag emoji. - * - * @param countryCode The two-letter country code (e.g., "US", "GB", "FR"). - * @return The flag emoji string, or an empty string if the code is invalid. - */ -fun countryCodeToFlagEmoji(countryCode: String): String { - if (countryCode.length != 2) return "" - - val uppercaseCode = countryCode.uppercase() - val baseCodePoint = 0x1F1E6 // Regional Indicator Symbol Letter A - val charCodeOffset = 'A'.code - - val firstChar = uppercaseCode[0].code - val secondChar = uppercaseCode[1].code - - val firstCodePoint = baseCodePoint + (firstChar - charCodeOffset) - val secondCodePoint = baseCodePoint + (secondChar - charCodeOffset) - - return String(intArrayOf(firstCodePoint, secondCodePoint), 0, 2) -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt index 01d09d44c9..f7e9722fce 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaChallengeContentState.kt @@ -43,14 +43,12 @@ import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor * ``` * * @property factorType The type of MFA factor being challenged (SMS or TOTP) - * @property maskedPhoneNumber For SMS factors, the masked phone number (e.g., "+1••••••890") * @property isLoading `true` when verification is in progress. Use this to show loading indicators. * @property error An optional error message to display to the user. Will be `null` if there's no error. * @property verificationCode The current value of the verification code input field. * @property resendTimer The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. * @property onVerificationCodeChange Callback invoked when the verification code input changes. * @property onVerifyClick Callback to verify the entered code and complete sign-in. - * @property onResendCodeClick For SMS only: Callback to resend the verification code. `null` for TOTP. * @property onCancelClick Callback to cancel the MFA challenge and return to sign-in. * * @since 10.0.0 @@ -59,9 +57,6 @@ data class MfaChallengeContentState( /** The type of MFA factor being challenged (SMS or TOTP). */ val factorType: MfaFactor, - /** For SMS: the masked phone number. For TOTP: null. */ - val maskedPhoneNumber: String? = null, - /** `true` when verification is in progress. Use to show loading indicators. */ val isLoading: Boolean = false, @@ -80,9 +75,6 @@ data class MfaChallengeContentState( /** Callback to verify the code and complete sign-in. */ val onVerifyClick: () -> Unit = {}, - /** For SMS only: Callback to resend the code. `null` for TOTP. */ - val onResendCodeClick: (() -> Unit)? = null, - /** Callback to cancel the challenge and return to sign-in. */ val onCancelClick: () -> Unit = {} ) { @@ -99,9 +91,4 @@ data class MfaChallengeContentState( val hasError: Boolean get() = !error.isNullOrBlank() - /** - * Returns true if the resend action is available (SMS only). - */ - val canResend: Boolean - get() = factorType == MfaFactor.Sms && onResendCodeClick != null } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt index b3850d027d..7d14351719 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentContentState.kt @@ -7,7 +7,6 @@ package com.battlelancer.seriesguide.backend.auth.mfa import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor -import com.battlelancer.seriesguide.backend.auth.data.CountryData import com.google.firebase.auth.MultiFactorInfo /** @@ -45,12 +44,6 @@ import com.google.firebase.auth.MultiFactorInfo * @property onFactorSelected (Step: [MfaEnrollmentStep.SelectFactor]) Callback invoked when the user selects an MFA factor. Receives the selected [MfaFactor]. * @property onSkipClick (Step: [MfaEnrollmentStep.SelectFactor]) Callback for the "Skip" action. Will be `null` if MFA enrollment is required via [com.firebase.ui.auth.configuration.MfaConfiguration.requireEnrollment]. * - * @property phoneNumber (Step: [MfaEnrollmentStep.ConfigureSms]) The current value of the phone number input field. Does not include country code prefix. - * @property onPhoneNumberChange (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the phone number input changes. Receives the new phone number string. - * @property selectedCountry (Step: [MfaEnrollmentStep.ConfigureSms]) The currently selected country for phone number formatting. Contains dial code, country code, and flag. - * @property onCountrySelected (Step: [MfaEnrollmentStep.ConfigureSms]) Callback invoked when the user selects a different country. Receives the new [CountryData]. - * @property onSendSmsCodeClick (Step: [MfaEnrollmentStep.ConfigureSms]) Callback to send the SMS verification code to the entered phone number. - * * @property totpSecret (Step: [MfaEnrollmentStep.ConfigureTotp]) The TOTP secret containing the shared key and configuration. Use this to display the secret key or access the underlying Firebase TOTP secret. * @property totpQrCodeUrl (Step: [MfaEnrollmentStep.ConfigureTotp]) A URI that can be rendered as a QR code or used as a deep link to open authenticator apps. Generated via [TotpSecret.generateQrCodeUrl]. * @property onContinueToVerifyClick (Step: [MfaEnrollmentStep.ConfigureTotp]) Callback to proceed to the verification step after the user has scanned the QR code or entered the secret. @@ -60,7 +53,6 @@ import com.google.firebase.auth.MultiFactorInfo * @property onVerifyClick (Step: [MfaEnrollmentStep.VerifyFactor]) Callback to verify the entered code and finalize MFA enrollment. * @property selectedFactor (Step: [MfaEnrollmentStep.VerifyFactor]) The MFA factor being verified (SMS or TOTP). Use this to customize UI messages. * @property resendTimer (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) The number of seconds remaining before the "Resend" action is available. Will be 0 when resend is allowed. - * @property onResendCodeClick (Step: [MfaEnrollmentStep.VerifyFactor], SMS only) Callback to resend the SMS verification code. Will be `null` for TOTP verification. * * @property recoveryCodes (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) A list of one-time backup codes the user should save. Only present if [com.firebase.ui.auth.configuration.MfaConfiguration.enableRecoveryCodes] is `true`. * @property onCodesSavedClick (Step: [MfaEnrollmentStep.ShowRecoveryCodes]) Callback invoked when the user confirms they have saved their recovery codes. Completes the enrollment flow. @@ -94,17 +86,6 @@ data class MfaEnrollmentContentState( val onSkipClick: (() -> Unit)? = null, - // ConfigureSms step - val phoneNumber: String = "", - - val onPhoneNumberChange: (String) -> Unit = {}, - - val selectedCountry: CountryData? = null, - - val onCountrySelected: (CountryData) -> Unit = {}, - - val onSendSmsCodeClick: () -> Unit = {}, - // ConfigureTotp step val totpSecret: TotpSecret? = null, @@ -123,8 +104,6 @@ data class MfaEnrollmentContentState( val resendTimer: Int = 0, - val onResendCodeClick: (() -> Unit)? = null, - // ShowRecoveryCodes step val recoveryCodes: List? = null, @@ -138,7 +117,6 @@ data class MfaEnrollmentContentState( val isValid: Boolean get() = when (step) { MfaEnrollmentStep.SelectFactor -> availableFactors.isNotEmpty() - MfaEnrollmentStep.ConfigureSms -> phoneNumber.isNotBlank() MfaEnrollmentStep.ConfigureTotp -> totpSecret != null && totpQrCodeUrl != null MfaEnrollmentStep.VerifyFactor -> verificationCode.length == 6 MfaEnrollmentStep.ShowRecoveryCodes -> !recoveryCodes.isNullOrEmpty() diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt index b6fa5ba231..b639a53889 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/MfaEnrollmentStep.kt @@ -24,12 +24,6 @@ enum class MfaEnrollmentStep { */ SelectFactor, - /** - * The user is configuring SMS-based MFA by entering their phone number. - * This step prepares to send an SMS verification code to the provided number. - */ - ConfigureSms, - /** * The user is configuring TOTP (Time-based One-Time Password) MFA. * This step presents the TOTP secret (as both text and QR code) for the user @@ -60,7 +54,6 @@ enum class MfaEnrollmentStep { */ fun MfaEnrollmentStep.getTitle(stringProvider: AuthUIStringProvider): String = when (this) { MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorTitle - MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsTitle MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpTitle MfaEnrollmentStep.VerifyFactor -> stringProvider.mfaStepVerifyFactorTitle MfaEnrollmentStep.ShowRecoveryCodes -> stringProvider.mfaStepShowRecoveryCodesTitle @@ -79,10 +72,8 @@ fun MfaEnrollmentStep.getHelperText( selectedFactor: MfaFactor? = null ): String = when (this) { MfaEnrollmentStep.SelectFactor -> stringProvider.mfaStepSelectFactorHelper - MfaEnrollmentStep.ConfigureSms -> stringProvider.mfaStepConfigureSmsHelper MfaEnrollmentStep.ConfigureTotp -> stringProvider.mfaStepConfigureTotpHelper MfaEnrollmentStep.VerifyFactor -> when (selectedFactor) { - MfaFactor.Sms -> stringProvider.mfaStepVerifyFactorSmsHelper MfaFactor.Totp -> stringProvider.mfaStepVerifyFactorTotpHelper null -> stringProvider.mfaStepVerifyFactorGenericHelper } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt deleted file mode 100644 index 06f5121c7a..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/mfa/SmsEnrollmentHandler.kt +++ /dev/null @@ -1,368 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.mfa - -import android.app.Activity -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler.Companion.RESEND_DELAY_SECONDS -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.FirebaseUser -import com.google.firebase.auth.PhoneAuthCredential -import com.google.firebase.auth.PhoneAuthProvider -import com.google.firebase.auth.PhoneMultiFactorGenerator -import kotlinx.coroutines.tasks.await - -/** - * Handler for SMS multi-factor authentication enrollment. - * - * This class manages the complete SMS enrollment flow, including: - * - Sending SMS verification codes to phone numbers - * - Resending codes with timer support - * - Verifying SMS codes entered by users - * - Finalizing enrollment with Firebase Authentication - * - * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure - * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. - * - * **Usage:** - * ```kotlin - * val handler = SmsEnrollmentHandler(auth, user) - * - * // Step 1: Send verification code - * val session = handler.sendVerificationCode("+1234567890") - * - * // Step 2: Display masked phone number and wait for user input - * val masked = session.getMaskedPhoneNumber() - * - * // Step 3: If needed, resend code after timer expires - * val newSession = handler.resendVerificationCode(session) - * - * // Step 4: Verify the code entered by the user - * val verificationCode = "123456" // From user input - * handler.enrollWithVerificationCode(session, verificationCode, "My Phone") - * ``` - * - * @property auth The [FirebaseAuth] instance - * @property user The [FirebaseUser] to enroll in SMS MFA - * - * @since 10.0.0 - * @see TotpEnrollmentHandler - * @see AuthProvider.Phone.verifyPhoneNumberAwait - */ -class SmsEnrollmentHandler( - private val activity: Activity, - private val auth: FirebaseAuth, - private val user: FirebaseUser -) { - private val phoneProvider = AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null, - smsCodeLength = SMS_CODE_LENGTH, - timeout = VERIFICATION_TIMEOUT_SECONDS, - isInstantVerificationEnabled = true - ) - /** - * Sends an SMS verification code to the specified phone number. - * - * This method initiates the SMS enrollment process by sending a verification code - * to the provided phone number. The code will be sent via SMS and should be - * displayed to the user for entry. - * - * **Important:** The user must re-authenticate before calling this method if their - * session is not recent. Use [FirebaseUser.reauthenticate] if needed. - * - * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890") - * @return An [SmsEnrollmentSession] containing the verification ID and metadata - * @throws Exception if the user needs to re-authenticate, phone number is invalid, - * or SMS sending fails - * - * @see resendVerificationCode - * @see SmsEnrollmentSession.getMaskedPhoneNumber - */ - suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession { - require(isValidPhoneNumber(phoneNumber)) { - "Phone number must be in E.164 format (e.g., +1234567890)" - } - - val multiFactorSession = user.multiFactor.session.await() - val result = phoneProvider.verifyPhoneNumberAwait( - auth = auth, - activity = activity, - phoneNumber = phoneNumber, - multiFactorSession = multiFactorSession, - forceResendingToken = null - ) - - return when (result) { - is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - SmsEnrollmentSession( - verificationId = "", // Not needed when auto-verified - phoneNumber = phoneNumber, - forceResendingToken = null, - sentAt = System.currentTimeMillis(), - autoVerifiedCredential = result.credential - ) - } - is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - SmsEnrollmentSession( - verificationId = result.verificationId, - phoneNumber = phoneNumber, - forceResendingToken = result.token, - sentAt = System.currentTimeMillis() - ) - } - } - } - - /** - * Resends the SMS verification code to the phone number. - * - * This method uses the force resending token from the original session to - * explicitly request a new SMS code. This should only be called after the - * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits. - * - * @param session The original [SmsEnrollmentSession] from [sendVerificationCode] - * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp - * @throws Exception if resending fails or if the session doesn't have a resend token - * - * @see sendVerificationCode - */ - suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession { - require(session.forceResendingToken != null) { - "Cannot resend code without a force resending token" - } - - val multiFactorSession = user.multiFactor.session.await() - val result = phoneProvider.verifyPhoneNumberAwait( - auth = auth, - activity = activity, - phoneNumber = session.phoneNumber, - multiFactorSession = multiFactorSession, - forceResendingToken = session.forceResendingToken - ) - - return when (result) { - is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { - SmsEnrollmentSession( - verificationId = "", // Not needed when auto-verified - phoneNumber = session.phoneNumber, - forceResendingToken = session.forceResendingToken, - sentAt = System.currentTimeMillis(), - autoVerifiedCredential = result.credential - ) - } - is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { - SmsEnrollmentSession( - verificationId = result.verificationId, - phoneNumber = session.phoneNumber, - forceResendingToken = result.token, - sentAt = System.currentTimeMillis() - ) - } - } - } - - /** - * Verifies an SMS code and completes the enrollment process. - * - * This method creates a multi-factor assertion using the provided session and - * verification code, then enrolls the user in SMS MFA with Firebase Authentication. - * - * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode] - * @param verificationCode The 6-digit code from the SMS message - * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone") - * @throws Exception if the verification code is invalid or if enrollment fails - * - * @see sendVerificationCode - * @see resendVerificationCode - */ - suspend fun enrollWithVerificationCode( - session: SmsEnrollmentSession, - verificationCode: String, - displayName: String? = null - ) { - require(isValidCodeFormat(verificationCode)) { - "Verification code must be 6 digits" - } - - val credential = session.autoVerifiedCredential - ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode) - - val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential) - user.multiFactor.enroll(multiFactorAssertion, displayName).await() - } - - /** - * Validates that a verification code has the correct format for SMS. - * - * This method performs basic client-side validation to ensure the code: - * - Is not null or empty - * - Contains only digits - * - Has exactly 6 digits (the standard SMS code length) - * - * **Note:** This does not verify the code against the server. Use - * [enrollWithVerificationCode] to perform actual verification with Firebase. - * - * @param code The verification code to validate - * @return `true` if the code has a valid format, `false` otherwise - */ - fun isValidCodeFormat(code: String): Boolean { - return code.isNotBlank() && - code.length == SMS_CODE_LENGTH && - code.all { it.isDigit() } - } - - /** - * Validates that a phone number is in the correct E.164 format. - * - * E.164 format requirements: - * - Starts with "+" - * - Followed by 1-15 digits - * - No spaces, hyphens, or other characters - * - Minimum 4 digits total (country code + subscriber number) - * - * Examples of valid numbers: - * - +1234567890 (US) - * - +447911123456 (UK) - * - +33612345678 (France) - * - * @param phoneNumber The phone number to validate - * @return `true` if the phone number is in E.164 format, `false` otherwise - */ - fun isValidPhoneNumber(phoneNumber: String): Boolean { - return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$")) - } - - companion object { - /** - * The standard length for SMS verification codes. - */ - const val SMS_CODE_LENGTH = 6 - - /** - * The verification timeout in seconds for phone authentication. - * This is how long Firebase will wait for auto-verification before - * falling back to manual code entry. - */ - const val VERIFICATION_TIMEOUT_SECONDS = 60L - - /** - * The recommended delay in seconds before allowing code resend. - * This prevents users from spamming the resend functionality and - * respects carrier rate limits. - */ - const val RESEND_DELAY_SECONDS = 30 - - /** - * The Firebase factor ID for SMS multi-factor authentication. - */ - const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID - } -} - -/** - * Represents an active SMS enrollment session with verification state. - * - * This class holds all the information needed to complete an SMS enrollment, - * including the verification ID, phone number, and resend token. - * - * @property verificationId The verification ID from Firebase - * @property phoneNumber The phone number being verified in E.164 format - * @property forceResendingToken Optional token for resending the SMS code - * @property sentAt Timestamp in milliseconds when the code was sent - * @property autoVerifiedCredential Optional credential if auto-verification succeeded - * - * @since 10.0.0 - */ -data class SmsEnrollmentSession( - val verificationId: String, - val phoneNumber: String, - val forceResendingToken: PhoneAuthProvider.ForceResendingToken?, - val sentAt: Long, - val autoVerifiedCredential: PhoneAuthCredential? = null -) { - /** - * Returns a masked version of the phone number for display purposes. - * - * Masks the middle digits of the phone number while keeping the country code - * and last few digits visible for user confirmation. - * - * Examples: - * - "+1234567890" → "+1••••••890" - * - "+447911123456" → "+44•••••••456" - * - * @return The masked phone number string - */ - fun getMaskedPhoneNumber(): String { - return maskPhoneNumber(phoneNumber) - } - - /** - * Checks if the resend delay has elapsed since the code was sent. - * - * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) - * @return `true` if enough time has passed to allow resending - */ - fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean { - val elapsed = (System.currentTimeMillis() - sentAt) / 1000 - return elapsed >= delaySec - } - - /** - * Returns the remaining seconds until resend is allowed. - * - * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) - * @return The number of seconds remaining, or 0 if resend is already allowed - */ - fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int { - val elapsed = (System.currentTimeMillis() - sentAt) / 1000 - return maxOf(0, delaySec - elapsed.toInt()) - } -} - -/** - * Masks the middle digits of a phone number for privacy. - * - * The function keeps the country code (first 1-3 characters after +) and - * the last 2-4 digits visible, masking everything in between with bullets. - * Longer phone numbers show more last digits for better user confirmation. - * - * Examples: - * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits) - * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits) - * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits) - * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits) - * - * @param phoneNumber The phone number to mask in E.164 format - * @return The masked phone number string - */ -fun maskPhoneNumber(phoneNumber: String): String { - if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { - return phoneNumber - } - - // Determine country code length (typically 1-3 digits after +) - val digitsOnly = phoneNumber.substring(1) // Remove + - val countryCodeLength = when { - digitsOnly.length > 10 -> 2 // Likely 2-digit country code - digitsOnly[0] == '1' -> 1 // North America - else -> 2 // Most other countries - } - - val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + - // Keep last 3-4 digits visible, with longer numbers showing more - 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 - } - val lastDigits = phoneNumber.takeLast(lastDigitsCount) - val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount - - return "$countryCode${"•".repeat(maskedLength)}$lastDigits" -} diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt index 66a01a82d7..340e7e559f 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/ErrorRecoveryDialog.kt @@ -1,5 +1,6 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 AND AGPL-3.0-or-later // SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. +// SPDX-FileCopyrightText: Copyright © 2026 Uwe Trottmann // Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android // https://github.com/firebase/FirebaseUI-Android @@ -123,6 +124,7 @@ private fun getRecoveryMessage( error.message?.takeIf { it.isNotBlank() && it != "Invalid credentials provided" } ?: stringProvider.invalidCredentialsRecoveryMessage } + is AuthException.UserNotFoundException -> stringProvider.userNotFoundRecoveryMessage is AuthException.WeakPasswordException -> { // Include specific reason if available @@ -140,31 +142,30 @@ private fun getRecoveryMessage( } ?: baseMessage } - is AuthException.TooManyRequestsException -> stringProvider.tooManyRequestsRecoveryMessage - is AuthException.PhoneVerificationCooldownException -> { - // Use the custom message which includes remaining cooldown time - error.message ?: stringProvider.unknownErrorRecoveryMessage - } is AuthException.MfaRequiredException -> stringProvider.mfaRequiredRecoveryMessage is AuthException.AccountLinkingRequiredException -> { // Use the custom message which includes email and provider details error.message ?: stringProvider.accountLinkingRequiredRecoveryMessage } + is AuthException.EmailMismatchException -> stringProvider.emailMismatchMessage is AuthException.InvalidEmailLinkException -> stringProvider.emailLinkInvalidLinkMessage is AuthException.EmailLinkWrongDeviceException -> stringProvider.emailLinkWrongDeviceMessage is AuthException.EmailLinkDifferentAnonymousUserException -> stringProvider.emailLinkDifferentAnonymousUserMessage + is AuthException.EmailLinkPromptForEmailException -> stringProvider.emailLinkPromptForEmailMessage is AuthException.EmailLinkCrossDeviceLinkingException -> { val providerName = error.providerName ?: stringProvider.emailProvider stringProvider.emailLinkCrossDeviceLinkingMessage(providerName) } + is AuthException.AuthCancelledException -> stringProvider.authCancelledRecoveryMessage is AuthException.UnknownException -> { // Use custom message if available (e.g., for configuration errors) error.message?.takeIf { it.isNotBlank() } ?: stringProvider.unknownErrorRecoveryMessage } + else -> stringProvider.unknownErrorRecoveryMessage } } @@ -192,9 +193,8 @@ private fun getRecoveryActionText( is AuthException.UserNotFoundException -> stringProvider.signupPageTitle // Navigate to sign-up when user not found is AuthException.NetworkException, is AuthException.InvalidCredentialsException, - is AuthException.WeakPasswordException, - is AuthException.TooManyRequestsException, - is AuthException.PhoneVerificationCooldownException -> stringProvider.retryAction + is AuthException.WeakPasswordException -> stringProvider.retryAction + is AuthException.UnknownException -> stringProvider.retryAction else -> stringProvider.retryAction @@ -214,8 +214,6 @@ private fun isRecoverable(error: AuthException): Boolean { is AuthException.UserNotFoundException -> true is AuthException.WeakPasswordException -> true is AuthException.EmailAlreadyInUseException -> true - is AuthException.TooManyRequestsException -> false // User must wait - is AuthException.PhoneVerificationCooldownException -> false // User must wait for cooldown is AuthException.MfaRequiredException -> true is AuthException.AccountLinkingRequiredException -> true is AuthException.AuthCancelledException -> true diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt index 89c1f4ad06..5c30cd9f02 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/FirebaseAuthScreen.kt @@ -65,8 +65,6 @@ import com.battlelancer.seriesguide.backend.auth.configuration.theme.LocalAuthUI import com.battlelancer.seriesguide.backend.auth.ui.components.LocalTopLevelDialogController import com.battlelancer.seriesguide.backend.auth.ui.components.rememberTopLevelDialogController import com.battlelancer.seriesguide.backend.auth.ui.method_picker.AuthMethodPicker -import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaChallengeScreen -import com.battlelancer.seriesguide.backend.auth.ui.screens.MfaEnrollmentScreen import com.battlelancer.seriesguide.backend.auth.ui.screens.email.EmailAuthScreen import com.battlelancer.seriesguide.backend.auth.util.EmailLinkPersistenceManager import com.battlelancer.seriesguide.backend.auth.util.SignInPreferenceManager @@ -323,7 +321,6 @@ fun FirebaseAuthScreen( user = user, auth = authUI.auth, configuration = mfaConfiguration, - authConfiguration = configuration, onComplete = { navController.popBackStack() }, onSkip = { navController.popBackStack() }, onError = { exception -> diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt index 100bbd69db..e2a9b49bdb 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeDefaults.kt @@ -36,7 +36,6 @@ import com.battlelancer.seriesguide.backend.auth.ui.components.VerificationCodeI @Composable internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { - val isSms = state.factorType == MfaFactor.Sms val stringProvider = LocalAuthUIStringProvider.current val verificationCodeValidator = remember { VerificationCodeValidator(stringProvider) @@ -51,25 +50,11 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = if (isSms) { - val phoneLabel = state.maskedPhoneNumber ?: "" - stringProvider.enterVerificationCodeTitle(phoneLabel) - } else { - stringProvider.mfaStepVerifyFactorTitle - }, + text = stringProvider.mfaStepVerifyFactorTitle, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center ) - if (isSms && state.maskedPhoneNumber != null) { - Text( - text = stringProvider.mfaStepVerifyFactorSmsHelper, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (state.error != null) { Text( text = state.error, @@ -92,43 +77,12 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) { Spacer(modifier = Modifier.height(8.dp)) - if (isSms) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton( - onClick = { state.onResendCodeClick?.invoke() }, - enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0 - ) { - Text( - text = if (state.resendTimer > 0) { - val minutes = state.resendTimer / 60 - val seconds = state.resendTimer % 60 - val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}" - stringProvider.resendCodeTimer(formatted) - } else { - stringProvider.resendCode - } - ) - } - - TextButton( - onClick = state.onCancelClick, - enabled = !state.isLoading - ) { - Text(stringProvider.useDifferentMethodAction) - } - } - } else { - OutlinedButton( - onClick = state.onCancelClick, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringProvider.dismissAction) - } + OutlinedButton( + onClick = state.onCancelClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringProvider.dismissAction) } Button( diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt index d3f57bb996..2636205384 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaChallengeScreen.kt @@ -6,7 +6,6 @@ package com.battlelancer.seriesguide.backend.auth.ui.screens -import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf @@ -16,21 +15,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor import com.battlelancer.seriesguide.backend.auth.mfa.MfaChallengeContentState -import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler -import com.battlelancer.seriesguide.backend.auth.mfa.maskPhoneNumber import com.google.firebase.auth.AuthResult import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.MultiFactorResolver -import com.google.firebase.auth.PhoneAuthOptions -import com.google.firebase.auth.PhoneAuthProvider -import com.google.firebase.auth.PhoneMultiFactorGenerator -import com.google.firebase.auth.PhoneMultiFactorInfo import com.google.firebase.auth.TotpMultiFactorGenerator import com.google.firebase.auth.TotpMultiFactorInfo import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await -import java.util.concurrent.TimeUnit /** * A stateful composable that manages the Multi-Factor Authentication (MFA) challenge flow @@ -70,7 +62,6 @@ fun MfaChallengeScreen( val isLoading = remember { mutableStateOf(false) } val error = remember { mutableStateOf(null) } val verificationCode = rememberSaveable { mutableStateOf("") } - val verificationId = remember { mutableStateOf(null) } val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } // Handle resend timer countdown @@ -86,71 +77,13 @@ fun MfaChallengeScreen( val factorType = remember { when (firstHint?.factorId) { - PhoneMultiFactorGenerator.FACTOR_ID -> MfaFactor.Sms TotpMultiFactorGenerator.FACTOR_ID -> MfaFactor.Totp - else -> MfaFactor.Sms - } - } - - val maskedPhoneNumber = remember { - if (firstHint is PhoneMultiFactorInfo) { - maskPhoneNumber(firstHint.phoneNumber) - } else null - } - - LaunchedEffect(firstHint) { - if (firstHint is PhoneMultiFactorInfo) { - isLoading.value = true - try { - val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { - override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { - coroutineScope.launch { - try { - val assertion = PhoneMultiFactorGenerator.getAssertion(credential) - val result = resolver.resolveSignIn(assertion).await() - onSuccess(result) - } catch (e: Exception) { - error.value = e.message - onError(e) - } - } - } - - override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { - error.value = e.message - onError(e) - isLoading.value = false - } - - override fun onCodeSent( - verId: String, - token: PhoneAuthProvider.ForceResendingToken - ) { - verificationId.value = verId - resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS - isLoading.value = false - } - } - - val options = PhoneAuthOptions.newBuilder() - .setMultiFactorHint(firstHint) - .setMultiFactorSession(resolver.session) - .setCallbacks(callbacks) - .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - - PhoneAuthProvider.verifyPhoneNumber(options) - } catch (e: Exception) { - error.value = e.message - onError(e) - isLoading.value = false - } + else -> MfaFactor.Totp } } val state = MfaChallengeContentState( factorType = factorType, - maskedPhoneNumber = maskedPhoneNumber, isLoading = isLoading.value, error = error.value, verificationCode = verificationCode.value, @@ -164,15 +97,6 @@ fun MfaChallengeScreen( isLoading.value = true try { val assertion = when (factorType) { - MfaFactor.Sms -> { - val verId = verificationId.value - require(verId != null) { "No verification ID available" } - val credential = PhoneAuthProvider.getCredential( - verId, - verificationCode.value - ) - PhoneMultiFactorGenerator.getAssertion(credential) - } MfaFactor.Totp -> { val totpInfo = firstHint as? TotpMultiFactorInfo require(totpInfo != null) { "No TOTP info available" } @@ -194,60 +118,6 @@ fun MfaChallengeScreen( } } }, - onResendCodeClick = if (factorType == MfaFactor.Sms && firstHint is PhoneMultiFactorInfo) { - { - if (resendTimerSeconds.intValue == 0) { - coroutineScope.launch { - isLoading.value = true - try { - val callbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() { - override fun onVerificationCompleted(credential: com.google.firebase.auth.PhoneAuthCredential) { - coroutineScope.launch { - try { - val assertion = PhoneMultiFactorGenerator.getAssertion(credential) - val result = resolver.resolveSignIn(assertion).await() - onSuccess(result) - } catch (e: Exception) { - error.value = e.message - onError(e) - } - } - } - - override fun onVerificationFailed(e: com.google.firebase.FirebaseException) { - error.value = e.message - onError(e) - isLoading.value = false - } - - override fun onCodeSent( - verId: String, - token: PhoneAuthProvider.ForceResendingToken - ) { - verificationId.value = verId - resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS - error.value = null - isLoading.value = false - } - } - - val options = PhoneAuthOptions.newBuilder() - .setMultiFactorHint(firstHint) - .setMultiFactorSession(resolver.session) - .setCallbacks(callbacks) - .setTimeout(SmsEnrollmentHandler.VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - - PhoneAuthProvider.verifyPhoneNumber(options) - } catch (e: Exception) { - error.value = e.message - onError(e) - isLoading.value = false - } - } - } - } - } else null, onCancelClick = onCancel ) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt index 4e32ab6e97..d406907504 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentDefaults.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -31,7 +32,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf @@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.AuthUIStringProvider import com.battlelancer.seriesguide.backend.auth.configuration.string_provider.LocalAuthUIStringProvider @@ -50,18 +49,14 @@ import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentStep import com.battlelancer.seriesguide.backend.auth.mfa.toMfaErrorMessage import com.battlelancer.seriesguide.backend.auth.ui.components.QrCodeImage import com.battlelancer.seriesguide.backend.auth.ui.components.ReauthenticationDialog -import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.EnterPhoneNumberUI -import com.battlelancer.seriesguide.backend.auth.ui.screens.phone.EnterVerificationCodeUI import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.MultiFactorInfo -import com.google.firebase.auth.PhoneMultiFactorInfo import com.google.firebase.auth.TotpMultiFactorInfo @Composable internal fun DefaultMfaEnrollmentContent( state: MfaEnrollmentContentState, - authConfiguration: AuthUIConfiguration, user: FirebaseUser ) { val stringProvider = LocalAuthUIStringProvider.current @@ -76,9 +71,11 @@ internal fun DefaultMfaEnrollmentContent( exception is FirebaseAuthRecentLoginRequiredException -> { showReauthDialog.value = true } + exception != null -> { snackbarHostState.showSnackbar(exception.toMfaErrorMessage(stringProvider)) } + !state.error.isNullOrBlank() -> { snackbarHostState.showSnackbar(state.error!!) } @@ -113,8 +110,10 @@ internal fun DefaultMfaEnrollmentContent( reauthErrorMessage.value = when { exception.message?.contains("password", ignoreCase = true) == true -> stringProvider.incorrectPasswordError + exception.message?.contains("network", ignoreCase = true) == true -> stringProvider.noInternet + else -> stringProvider.reauthGenericError } } @@ -136,21 +135,6 @@ internal fun DefaultMfaEnrollmentContent( ) } - MfaEnrollmentStep.ConfigureSms -> { - state.selectedCountry?.let { country -> - EnterPhoneNumberUI( - configuration = authConfiguration, - isLoading = state.isLoading, - phoneNumber = state.phoneNumber, - selectedCountry = country, - onPhoneNumberChange = state.onPhoneNumberChange, - onCountrySelected = state.onCountrySelected, - onSendCodeClick = state.onSendSmsCodeClick, - title = stringProvider.mfaEnrollmentEnterPhoneNumber - ) - } - } - MfaEnrollmentStep.ConfigureTotp -> { ConfigureTotpUI( totpSecret = state.totpSecret?.sharedSecretKey, @@ -166,23 +150,6 @@ internal fun DefaultMfaEnrollmentContent( MfaEnrollmentStep.VerifyFactor -> { when (state.selectedFactor) { - MfaFactor.Sms -> { - val formattedPhone = - "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}" - EnterVerificationCodeUI( - configuration = authConfiguration, - isLoading = state.isLoading, - verificationCode = state.verificationCode, - fullPhoneNumber = formattedPhone, - resendTimer = state.resendTimer, - onVerificationCodeChange = state.onVerificationCodeChange, - onVerifyCodeClick = state.onVerifyClick, - onResendCodeClick = state.onResendCodeClick ?: {}, - onChangeNumberClick = state.onBackClick, - title = stringProvider.mfaEnrollmentVerifySmsCode - ) - } - MfaFactor.Totp -> { VerifyTotpUI( verificationCode = state.verificationCode, @@ -234,7 +201,6 @@ private fun SelectFactorUI( ) { val enrolledFactorIds = enrolledFactors.mapNotNull { when (it) { - is PhoneMultiFactorInfo -> MfaFactor.Sms is TotpMultiFactorInfo -> MfaFactor.Totp else -> null } @@ -308,7 +274,6 @@ private fun SelectFactorUI( modifier = Modifier.fillMaxWidth() ) { when (factor) { - MfaFactor.Sms -> Text(stringProvider.mfaStepConfigureSmsTitle) MfaFactor.Totp -> Text(stringProvider.mfaStepConfigureTotpTitle) } } @@ -358,7 +323,6 @@ private fun EnrolledFactorItem( Column(modifier = Modifier.weight(1f)) { Text( text = when (factorInfo) { - is PhoneMultiFactorInfo -> stringProvider.smsAuthenticationLabel is TotpMultiFactorInfo -> stringProvider.totpAuthenticationLabel else -> stringProvider.unknownMethodLabel }, @@ -366,8 +330,9 @@ private fun EnrolledFactorItem( ) Text( text = when (factorInfo) { - is PhoneMultiFactorInfo -> factorInfo.phoneNumber ?: stringProvider.smsAuthenticationLabel - is TotpMultiFactorInfo -> factorInfo.displayName ?: stringProvider.totpAuthenticationLabel + is TotpMultiFactorInfo -> factorInfo.displayName + ?: stringProvider.totpAuthenticationLabel + else -> "" }, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt index e0ff556c8b..fc1a89a353 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/screens/MfaEnrollmentScreen.kt @@ -6,7 +6,6 @@ package com.battlelancer.seriesguide.backend.auth.ui.screens -import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf @@ -14,18 +13,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.platform.LocalContext -import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.MfaConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.MfaFactor -import com.battlelancer.seriesguide.backend.auth.configuration.authUIConfiguration -import com.battlelancer.seriesguide.backend.auth.configuration.auth_provider.AuthProvider -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import com.battlelancer.seriesguide.backend.auth.util.CountryUtils import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentContentState import com.battlelancer.seriesguide.backend.auth.mfa.MfaEnrollmentStep -import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentHandler -import com.battlelancer.seriesguide.backend.auth.mfa.SmsEnrollmentSession import com.battlelancer.seriesguide.backend.auth.mfa.TotpEnrollmentHandler import com.battlelancer.seriesguide.backend.auth.mfa.TotpSecret import com.google.firebase.auth.FirebaseAuth @@ -61,19 +52,13 @@ fun MfaEnrollmentScreen( user: FirebaseUser, auth: FirebaseAuth, configuration: MfaConfiguration, - authConfiguration: AuthUIConfiguration? = null, onComplete: () -> Unit, onSkip: () -> Unit = {}, onError: (Exception) -> Unit = {}, content: @Composable ((MfaEnrollmentContentState) -> Unit)? = null ) { - val activity = requireNotNull(LocalActivity.current) { - "MfaEnrollmentScreen must be used within an Activity context for SMS verification" - } val coroutineScope = rememberCoroutineScope() - val applicationContext = LocalContext.current.applicationContext - val smsHandler = remember(activity, auth, user) { SmsEnrollmentHandler(activity, auth, user) } val totpHandler = remember(auth, user) { TotpEnrollmentHandler(auth, user) } val currentStep = rememberSaveable { mutableStateOf(MfaEnrollmentStep.SelectFactor) } @@ -83,10 +68,6 @@ fun MfaEnrollmentScreen( val lastException = remember { mutableStateOf(null) } val enrolledFactors = remember { mutableStateOf(user.multiFactor.enrolledFactors) } - val phoneNumber = rememberSaveable { mutableStateOf("") } - val selectedCountry = remember { mutableStateOf(CountryUtils.getDefaultCountry()) } - val smsSession = remember { mutableStateOf(null) } - val totpSecret = remember { mutableStateOf(null) } val totpQrCodeUrl = remember { mutableStateOf(null) } @@ -96,21 +77,6 @@ fun MfaEnrollmentScreen( val resendTimerSeconds = rememberSaveable { mutableIntStateOf(0) } - val phoneAuthConfiguration = remember(authConfiguration, applicationContext) { - authConfiguration ?: authUIConfiguration { - context = applicationContext - providers { - provider( - AuthProvider.Phone( - defaultNumber = null, - defaultCountryCode = null, - allowedCountries = null - ) - ) - } - } - } - // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { if (resendTimerSeconds.intValue > 0) { @@ -123,7 +89,6 @@ fun MfaEnrollmentScreen( if (configuration.allowedFactors.size == 1) { selectedFactor.value = configuration.allowedFactors.first() when (selectedFactor.value) { - MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms MfaFactor.Totp -> { currentStep.value = MfaEnrollmentStep.ConfigureTotp isLoading.value = true @@ -157,17 +122,15 @@ fun MfaEnrollmentScreen( onBackClick = { when (currentStep.value) { MfaEnrollmentStep.SelectFactor -> {} - MfaEnrollmentStep.ConfigureSms, MfaEnrollmentStep.ConfigureTotp -> { + MfaEnrollmentStep.ConfigureTotp -> { currentStep.value = MfaEnrollmentStep.SelectFactor selectedFactor.value = null - phoneNumber.value = "" totpSecret.value = null totpQrCodeUrl.value = null } MfaEnrollmentStep.VerifyFactor -> { verificationCode.value = "" when (selectedFactor.value) { - MfaFactor.Sms -> currentStep.value = MfaEnrollmentStep.ConfigureSms MfaFactor.Totp -> currentStep.value = MfaEnrollmentStep.ConfigureTotp null -> currentStep.value = MfaEnrollmentStep.SelectFactor } @@ -184,9 +147,6 @@ fun MfaEnrollmentScreen( onFactorSelected = { factor -> selectedFactor.value = factor when (factor) { - MfaFactor.Sms -> { - currentStep.value = MfaEnrollmentStep.ConfigureSms - } MfaFactor.Totp -> { currentStep.value = MfaEnrollmentStep.ConfigureTotp coroutineScope.launch { @@ -240,35 +200,6 @@ fun MfaEnrollmentScreen( onSkipClick = if (!configuration.requireEnrollment) { { onSkip() } } else null, - phoneNumber = phoneNumber.value, - onPhoneNumberChange = { phone -> - phoneNumber.value = phone - error.value = null - }, - selectedCountry = selectedCountry.value, - onCountrySelected = { country -> - selectedCountry.value = country - }, - onSendSmsCodeClick = { - coroutineScope.launch { - isLoading.value = true - try { - val fullPhoneNumber = "${selectedCountry.value.dialCode}${phoneNumber.value}" - val session = smsHandler.sendVerificationCode(fullPhoneNumber) - smsSession.value = session - currentStep.value = MfaEnrollmentStep.VerifyFactor - resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS - error.value = null - lastException.value = null - } catch (e: Exception) { - error.value = e.message - lastException.value = e - onError(e) - } finally { - isLoading.value = false - } - } - }, totpSecret = totpSecret.value, totpQrCodeUrl = totpQrCodeUrl.value, onContinueToVerifyClick = { @@ -284,18 +215,6 @@ fun MfaEnrollmentScreen( isLoading.value = true try { when (selectedFactor.value) { - MfaFactor.Sms -> { - val session = smsSession.value - if (session != null) { - smsHandler.enrollWithVerificationCode( - session = session, - verificationCode = verificationCode.value, - displayName = "SMS" - ) - } else { - throw IllegalStateException("No SMS session available") - } - } MfaFactor.Totp -> { val secret = totpSecret.value if (secret != null) { @@ -333,31 +252,6 @@ fun MfaEnrollmentScreen( }, selectedFactor = selectedFactor.value, resendTimer = resendTimerSeconds.intValue, - onResendCodeClick = if (selectedFactor.value == MfaFactor.Sms) { - { - if (resendTimerSeconds.intValue == 0) { - coroutineScope.launch { - val session = smsSession.value - if (session != null) { - isLoading.value = true - try { - val newSession = smsHandler.resendVerificationCode(session) - smsSession.value = newSession - resendTimerSeconds.intValue = SmsEnrollmentHandler.RESEND_DELAY_SECONDS - error.value = null - lastException.value = null - } catch (e: Exception) { - error.value = e.message - lastException.value = e - onError(e) - } finally { - isLoading.value = false - } - } - } - } - } - } else null, recoveryCodes = recoveryCodes.value, onCodesSavedClick = { onComplete() @@ -369,7 +263,6 @@ fun MfaEnrollmentScreen( } else { DefaultMfaEnrollmentContent( state = state, - authConfiguration = phoneAuthConfiguration, user = user ) } diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt deleted file mode 100644 index 256d344b67..0000000000 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/util/CountryUtils.kt +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright © 2025 Google Inc. All Rights Reserved. - -// Original file by Google Inc. licensed under Apache-2.0 copied from FirebaseUI-Android -// https://github.com/firebase/FirebaseUI-Android - -package com.battlelancer.seriesguide.backend.auth.util - -import com.battlelancer.seriesguide.backend.auth.data.ALL_COUNTRIES -import com.battlelancer.seriesguide.backend.auth.data.CountryData -import java.text.Normalizer -import java.util.Locale - -/** - * Utility functions for searching and filtering countries. - */ -object CountryUtils { - - // Lazy-initialized maps for fast lookups - private val countryCodeMap: Map by lazy { - ALL_COUNTRIES.associateBy { it.countryCode.uppercase() } - } - - private val dialCodeMap: Map> by lazy { - ALL_COUNTRIES.groupBy { it.dialCode } - } - - /** - * Finds a country by its ISO 3166-1 alpha-2 country code. - * - * @param countryCode The two-letter country code (e.g., "US", "GB"). - * @return The CountryData or null if not found. - */ - fun findByCountryCode(countryCode: String): CountryData? { - return countryCodeMap[countryCode.uppercase()] - } - - /** - * Finds all countries with the given dial code. - * - * @param dialCode The international dialing code (e.g., "+1", "+44"). - * @return List of countries with that dial code, or empty list if none found. - */ - fun findByDialCode(dialCode: String): List { - return dialCodeMap[dialCode] ?: emptyList() - } - - /** - * Searches for countries by name. Supports partial matching and diacritic-insensitive search. - * - * @param query The search query. - * @return List of countries matching the query, or empty list if none found. - */ - fun searchByName(query: String): List { - val trimmedQuery = query.trim() - if (trimmedQuery.isEmpty()) return emptyList() - - val normalizedQuery = normalizeString(trimmedQuery) - - return ALL_COUNTRIES.filter { country -> - normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) - } - } - - /** - * Searches for countries by name, country code, or dial code. - * Supports partial matching and diacritic-insensitive search. - * - * @param query The search query (country name, country code, or dial code). - * @return List of countries matching the query, sorted by relevance. - */ - fun search(query: String): List { - val trimmedQuery = query.trim() - if (trimmedQuery.isEmpty()) return emptyList() - - val normalizedQuery = normalizeString(trimmedQuery) - val uppercaseQuery = trimmedQuery.uppercase() - - return ALL_COUNTRIES.filter { country -> - // Match by country name (partial, case-insensitive, diacritic-insensitive) - normalizeString(country.name).contains(normalizedQuery, ignoreCase = true) || - // Match by country code (partial, case-insensitive) - country.countryCode.uppercase().contains(uppercaseQuery) || - // Match by dial code (partial) - country.dialCode.contains(trimmedQuery) - }.sortedWith( - compareBy( - // Prioritize exact matches first - { country -> - when { - country.countryCode.uppercase() == uppercaseQuery -> 0 - country.dialCode == trimmedQuery -> 1 - normalizeString(country.name) == normalizedQuery -> 2 - else -> 3 - } - }, - // Then sort alphabetically by name - { country -> country.name } - ) - ) - } - - /** - * Filters countries by allowed country codes. - * - * @param allowedCountryCodes Set of allowed ISO 3166-1 alpha-2 country codes. - * @return List of countries that are in the allowed set. - */ - fun filterByAllowedCountries(allowedCountryCodes: Set): List { - if (allowedCountryCodes.isEmpty()) return ALL_COUNTRIES - - val uppercaseAllowed = allowedCountryCodes.map { it.uppercase() }.toSet() - return ALL_COUNTRIES.filter { it.countryCode.uppercase() in uppercaseAllowed } - } - - /** - * Gets the default country based on the device's locale. - * - * @return The CountryData for the device's country, or United States as fallback. - */ - fun getDefaultCountry(): CountryData { - val deviceCountryCode = Locale.getDefault().country - return findByCountryCode(deviceCountryCode) ?: findByCountryCode("US")!! - } - - /** - * Formats a phone number with the country's dial code. - * - * @param dialCode The country dial code (e.g., "+1"). - * @param phoneNumber The local phone number. - * @return The formatted international phone number. - */ - fun formatPhoneNumber(dialCode: String, phoneNumber: String): String { - val cleanNumber = phoneNumber.replace(Regex("[^0-9]"), "") - return "$dialCode$cleanNumber" - } - - /** - * Normalizes a string by removing diacritics and converting to lowercase. - * - * @param value The string to normalize. - * @return The normalized string. - */ - private fun normalizeString(value: String): String { - return Normalizer.normalize(value, Normalizer.Form.NFD) - .replace(Regex("\\p{M}"), "") - .lowercase() - } -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd9d5edd07..abd169ce46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -661,7 +661,6 @@ Remove Back Verify - Use a different method I\'ve saved these codes Secret key Verification code @@ -673,16 +672,13 @@ Active methods Add new method All available authentication methods are enrolled - SMS authentication Authenticator app Unknown method Enrolled on %1$s Scan the QR code or enter the secret key in your authenticator app Choose a verification method Add an extra layer of security - SMS Authenticator app - This phone number is associated with another account Verification required Scan the QR code with your authenticator app Authenticator app is already set up @@ -800,22 +796,6 @@ Dismiss You originally intended to connect %1$s to your email account but have opened the link on a different device where you are not signed in.\n\nIf you still want to connect your %1$s account, open the link on the same device where you started sign-in. Otherwise, tap Continue to sign-in on this device. - - - Enter the 6-digit code we sent to - Resend code in %1$s - Verify your phone number - Verifying… - Wrong code. Try again. - This phone number has been used too many times - There was a problem verifying your phone number - This code is no longer valid - Sign in with phone number - Phone number automatically verified - Resend Code - By tapping "%1$s", an SMS may be sent. Message & data rates may apply. - By tapping "%1$s", you are indicating that you accept our %2$s and %3$s. An SMS may be sent. Message & data rates may apply. - Authentication Error Try again @@ -825,23 +805,18 @@ Choose Authentication Method - Set Up SMS Verification Set Up Authenticator App Verify Your Code Save Your Recovery Codes Select a second authentication method to secure your account - Enter your phone number to receive verification codes Scan the QR code with your authenticator app - Enter the code sent to your phone Enter the code from your authenticator app Enter your verification code Store these codes in a safe place. You can use them to sign in if you lose access to your authentication method. - Set Up SMS Authentication - Verify SMS Code - + Wrong code. Try again. This operation requires recent authentication. Please sign in again and try again. The verification code is incorrect. Please try again. A network error occurred. Please check your connection and try again. From 78c52eadad39eb45a26e9568d599a08d66246e1b Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 09:55:02 +0100 Subject: [PATCH 014/150] Auth: remove unused imports --- .../ui/components/VerificationCodeInputField.kt | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt index 719fbba654..0927468dd9 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/ui/components/VerificationCodeInputField.kt @@ -8,7 +8,6 @@ package com.battlelancer.seriesguide.backend.auth.ui.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,38 +17,33 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.draw.clip -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -58,10 +52,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.core.text.isDigitsOnly import com.battlelancer.seriesguide.backend.auth.configuration.theme.AuthUITheme import com.battlelancer.seriesguide.backend.auth.configuration.validators.FieldValidator From 6d75af5038205f0c2293698f73472c00f10372a7 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 10:07:53 +0100 Subject: [PATCH 015/150] Auth: use default_web_client_id from this apps resources --- .../backend/auth/configuration/auth_provider/AuthProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt index c902c7c2e9..994a19e957 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/auth/configuration/auth_provider/AuthProvider.kt @@ -16,7 +16,7 @@ import androidx.credentials.ClearCredentialStateRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.datastore.preferences.core.stringPreferencesKey -import com.firebase.ui.auth.R +import com.battlelancer.seriesguide.R import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfiguration import com.battlelancer.seriesguide.backend.auth.configuration.AuthUIConfigurationDsl import com.battlelancer.seriesguide.backend.auth.configuration.PasswordRule From 723ae4f686ea623b3f9443bccf07479c60b70d84 Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 5 Mar 2026 10:11:15 +0100 Subject: [PATCH 016/150] Cloud: drop no longer supported silent sign-in --- .../seriesguide/backend/HexagonTools.kt | 50 +++---------------- 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt index a060923a91..c01cc150ee 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/backend/HexagonTools.kt @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Copyright 2014-2025 Uwe Trottmann +// SPDX-FileCopyrightText: Copyright © 2014 Uwe Trottmann package com.battlelancer.seriesguide.backend @@ -10,7 +10,6 @@ import com.battlelancer.seriesguide.backend.CloudEndpointUtils.updateBuilder import com.battlelancer.seriesguide.backend.settings.HexagonSettings import com.battlelancer.seriesguide.jobs.NetworkJobProcessor import com.battlelancer.seriesguide.modules.ApplicationContext -import com.battlelancer.seriesguide.util.Errors import com.battlelancer.seriesguide.util.Errors.Companion.logAndReportHexagon import com.battlelancer.seriesguide.util.isRetryError import com.github.michaelbull.result.Err @@ -20,7 +19,6 @@ import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.throwUnless import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.gms.tasks.Tasks import com.google.api.client.http.HttpRequestInitializer import com.google.api.client.http.HttpTransport import com.google.api.client.http.javanet.NetHttpTransport @@ -34,9 +32,7 @@ import com.uwetrottmann.seriesguide.backend.lists.Lists import com.uwetrottmann.seriesguide.backend.movies.Movies import com.uwetrottmann.seriesguide.backend.shows.Shows import com.uwetrottmann.seriesguide.backend.shows.model.SgCloudShow -import timber.log.Timber import java.io.IOException -import java.util.concurrent.ExecutionException import javax.inject.Inject import javax.inject.Singleton @@ -178,8 +174,10 @@ class HexagonTools @Inject constructor( * Make sure to check [FirebaseHttpRequestInitializer.firebaseUser] is not null (the * account might have gotten signed out). * - * @param checkSignInState If enabled, tries to silently sign in with Google. If it fails, sets - * the [HexagonSettings.setShouldValidateAccount] flag. If successful, clears the flag. + * @param checkSignInState If set, tries to retrieve the signed in user from [FirebaseAuth] (if + * [FirebaseHttpRequestInitializer.firebaseUser] is null and [isTimeForSignInStateCheck]). + * If it fails, sets the [HexagonSettings.setShouldValidateAccount] flag. + * If successful, clears the flag. */ @Synchronized private fun getHttpRequestInitializer(checkSignInState: Boolean): FirebaseHttpRequestInitializer { @@ -204,45 +202,10 @@ class HexagonTools @Inject constructor( } lastSignInCheck = SystemClock.elapsedRealtime() - var account = FirebaseAuth.getInstance().currentUser + val account = FirebaseAuth.getInstance().currentUser if (account != null) { // still signed in httpRequestInitializer.firebaseUser = account - } else { - // Try to silently sign in. This is fine as Cloud was enabled by the user - // and they reasonably expect to stay signed in. - val signInTask = AuthUI.getInstance().silentSignIn(context, firebaseSignInProviders) - try { - val authResult = Tasks.await(signInTask) - if (authResult?.user != null) { - Timber.i("%s: successful", ACTION_SILENT_SIGN_IN) - authResult.user.let { - account = it - httpRequestInitializer.firebaseUser = it - } - } else { - Errors.logAndReportHexagonAuthError( - HexagonAuthError(ACTION_SILENT_SIGN_IN, "FirebaseUser is null") - ) - } - } catch (e: Exception) { - // https://developers.google.com/android/reference/com/google/android/gms/tasks/Tasks#public-static-tresult-await-tasktresult-task - if (e is InterruptedException) { - // Do not report thread interruptions, it's expected. - Timber.w(e, ACTION_SILENT_SIGN_IN) - } else { - val cause = if (e is ExecutionException) { - e.cause ?: e // The Task failed, getCause returns the original exception. - } else { - e // Unexpected exception. - } - // Do not report sign in required errors, this is expected and handled below. - val authEx = HexagonAuthError.build(ACTION_SILENT_SIGN_IN, cause) - if (!authEx.isSignInRequiredError()) { - Errors.logAndReportHexagonAuthError(authEx) - } - } - } } val shouldFixAccount = account == null @@ -297,7 +260,6 @@ class HexagonTools @Inject constructor( } companion object { - private const val ACTION_SILENT_SIGN_IN = "silent sign-in" private val JSON_FACTORY: JsonFactory = GsonFactory() private val HTTP_TRANSPORT: HttpTransport = NetHttpTransport() private const val SIGN_IN_CHECK_INTERVAL_MS = 5 * DateUtils.MINUTE_IN_MILLIS From af28d525a969d140da5a3c41f0784b7f6cfdf97a Mon Sep 17 00:00:00 2001 From: Uwe Date: Thu, 20 Nov 2025 11:14:22 +0100 Subject: [PATCH 017/150] Cloud: drop edge-to-edge opt-out for Firebase Auth UI --- app/src/main/res/values-v35/themes.xml | 13 ------------- app/src/main/res/values/themes.xml | 10 ---------- build.gradle.kts | 2 -- 3 files changed, 25 deletions(-) delete mode 100644 app/src/main/res/values-v35/themes.xml diff --git a/app/src/main/res/values-v35/themes.xml b/app/src/main/res/values-v35/themes.xml deleted file mode 100644 index 50a55f6f47..0000000000 --- a/app/src/main/res/values-v35/themes.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3949e7db33..0d2e96b3cc 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -11,16 +11,6 @@ -